running-tools

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

commit 870d74eef70abe41c1ff39407bf97afaeb112fb6
parent bd0cb27d9a7ec8f0397d19edda33088193540c56
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Thu, 11 Jul 2024 13:16:33 -0700

Merge branch 'dev'

Diffstat:
M.github/workflows/node.js.yml | 2++
M.gitignore | 4++++
M404.html | 9+++++++--
MCHANGELOG.md | 16++++++++++++++++
MREADME.md | 7++++++-
Mindex.html | 4++--
Mpackage-lock.json | 999+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Mpackage.json | 26+++++++++++++++-----------
Aplaywright.config.js | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mscripts/deploy.sh | 0
Msrc/App.vue | 4++--
Msrc/assets/global.css | 12++++--------
Msrc/assets/target-calculator.css | 7+++----
Msrc/components/DecimalInput.vue | 173+++++++++++++++++++++++++++++++++++++------------------------------------------
Asrc/components/DoubleOutputTable.vue | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/IntegerInput.vue | 152+++++++++++++++++++++++++++++++++++++------------------------------------------
Asrc/components/PaceInput.vue | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/RaceOptions.vue | 30++++++++++++++++++++++++++++++
Dsrc/components/SimpleTargetTable.vue | 144-------------------------------------------------------------------------------
Asrc/components/SingleOutputTable.vue | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/SplitOutputTable.vue | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/components/TargetEditor.vue | 278++++++++++++++++++++++++++++++++++++++++---------------------------------------
Msrc/components/TargetSetSelector.vue | 231+++++++++++++++++++++++++++++++++++++------------------------------------------
Msrc/components/TimeInput.vue | 227++++++++++++++++++++++++++++++++++++-------------------------------------------
Asrc/composables/useStorage.js | 36++++++++++++++++++++++++++++++++++++
Msrc/router/index.js | 20++++++++++++++++++++
Asrc/utils/calculators.js | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/utils/format.js | 9++-------
Dsrc/utils/localStorage.js | 40----------------------------------------
Msrc/utils/paces.js | 42++++++++++++++----------------------------
Msrc/utils/races.js | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Msrc/utils/targets.js | 152++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Msrc/utils/units.js | 44+++++++++++++-------------------------------
Msrc/views/AboutPage.vue | 95+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Asrc/views/BatchCalculator.vue | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/views/HomePage.vue | 36++++++++++++++++++++----------------
Msrc/views/NotFoundPage.vue | 17+++++++----------
Msrc/views/PaceCalculator.vue | 221++++++++++++++-----------------------------------------------------------------
Msrc/views/RaceCalculator.vue | 367++++++++++++++-----------------------------------------------------------------
Msrc/views/SplitCalculator.vue | 273++++++++++++++++---------------------------------------------------------------
Msrc/views/UnitCalculator.vue | 345++++++++++++++++++++++++++++---------------------------------------------------
Asrc/views/WorkoutCalculator.vue | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/batch-calculator.spec.js | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/cross-calculator.spec.js | 206+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/pace-calculator.spec.js | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/race-calculator.spec.js | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/split-calculator.spec.js | 191+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/unit-calculator.spec.js | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/e2e/workout-calculator.spec.js | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/DoubleOutputTable.spec.js | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/PaceInput.spec.js | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/RaceOptions.spec.js | 38++++++++++++++++++++++++++++++++++++++
Dtests/unit/components/SimpleTargetTable.spec.js | 123-------------------------------------------------------------------------------
Atests/unit/components/SingleOutputTable.spec.js | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/SplitOutputTable.spec.js | 157+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/components/TargetEditor.spec.js | 444+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtests/unit/components/TargetSetSelector.spec.js | 258++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mtests/unit/components/TimeInput.spec.js | 4++--
Atests/unit/utils/calculators.spec.js | 201+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/utils/format.spec.js | 2+-
Mtests/unit/utils/paces.spec.js | 20+++++++-------------
Mtests/unit/utils/races.spec.js | 176++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mtests/unit/utils/targets.spec.js | 14+++++++-------
Mtests/unit/utils/units.spec.js | 2+-
Atests/unit/views/BatchCalculator.spec.js | 419+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/views/PaceCalculator.spec.js | 192+++++++++++++++++++++++++++++++++++++------------------------------------------
Mtests/unit/views/RaceCalculator.spec.js | 275+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mtests/unit/views/SplitCalculator.spec.js | 568+++++++++++++++++--------------------------------------------------------------
Mtests/unit/views/UnitCalculator.spec.js | 56++++++++++++++++++++++++++++++++++++--------------------
Atests/unit/views/WorkoutCalculator.spec.js | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvite.config.js | 1+
71 files changed, 6182 insertions(+), 3318 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/404.html b/404.html @@ -26,9 +26,14 @@ font-size: 2em; font-weight: bold; } + main { + margin: 1em; + } h1 { font-size: 1.5em; - margin: 10px 10px 0px; + } + p { + margin-top: 0.5em; } </style> </head> @@ -37,7 +42,7 @@ <header>Running Tools</header> <main> <h1>404 Not Found</h1> - <p><a href="%BASE_URL%">homepage</a></p> + <p><a href="%BASE_URL%">Return home</a></p> </main> </body> </html> diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 1.4.0 - 2024-07-11 + +### Added +- Batch Calculator +- Workout Calculator + +### Changed +- The edit target set dialog is opened automatically after a new target set is + created +- Target sets can only be used by the calculator they were created in (and by + the Batch Calculator for pace, race, and workout target sets) + +### Fixed +- Bug that prevented Split Calculator splits from being saved after a new target + set was created + ## 1.3.0 - 2024-03-25 ### Added diff --git a/README.md b/README.md @@ -3,6 +3,8 @@ A collection of tools for runners and their coaches. Try it out [here](https://ashermorgan.github.io/running-tools/). ## Features +- [Batch Calculator](https://ashermorgan.github.io/running-tools/#/calculate/batch): + Create tables of the results of the other calculators over a range of inputs - [Pace Calculator](https://ashermorgan.github.io/running-tools/#/calculate/paces): Calculate distances and times that are at the same pace - [Race Calculator](https://ashermorgan.github.io/running-tools/#/calculate/races): @@ -11,6 +13,8 @@ Try it out [here](https://ashermorgan.github.io/running-tools/). Find splits, paces, and cumulative times for the segments of a race - [Unit Calculator](https://ashermorgan.github.io/running-tools/#/calculate/units): Convert between different distance, time, speed, and pace units +- [Workout Calculator](https://ashermorgan.github.io/running-tools/#/calculate/workouts): + Estimate target workout splits using previous race results ## Setup Install dependencies @@ -23,10 +27,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/index.html b/index.html @@ -76,12 +76,12 @@ font-weight: bold; } p { - margin: 10px; + margin: 1em; font-weight: bold; } </style> <header>Running Tools</header> - <p>We're sorry but Running Tools doesn't work properly without JavaScript enabled. Please enable it to continue.</p> + <p>Running Tools requires JavaScript. Please enable it to continue.</p> </noscript> <!-- built files will be auto injected --> diff --git a/package-lock.json b/package-lock.json @@ -1,27 +1,29 @@ { "name": "running-tools", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "running-tools", - "version": "1.3.0", + "version": "1.4.0", "dependencies": { - "feather-icons": "^4.29.0", - "vue": "^3.3.4", + "feather-icons": "^4.29.2", + "vue": "^3.4.27", "vue-feather": "^2.0.0", - "vue-router": "^4.2.2" + "vue-router": "^4.3.2" }, "devDependencies": { - "@vitejs/plugin-vue": "^4.2.3", - "@vue/test-utils": "^2.4.0", - "eslint": "^8.39.0", - "eslint-plugin-vue": "^9.11.0", + "@playwright/test": "^1.44.0", + "@types/node": "^20.12.12", + "@vitejs/plugin-vue": "^4.6.2", + "@vue/test-utils": "^2.4.6", + "eslint": "^8.57.0", + "eslint-plugin-vue": "^9.26.0", "jsdom": "^22.1.0", "pwa-asset-generator": "^6.3.1", - "vite": "^4.3.9", - "vite-plugin-pwa": "^0.16.4", + "vite": "^4.5.3", + "vite-plugin-pwa": "^0.16.7", "vitest": "^0.32.4" } }, @@ -644,9 +646,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", + "integrity": "sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg==", "bin": { "parser": "bin/babel-parser.js" }, @@ -2293,23 +2295,23 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.1.tgz", - "integrity": "sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==", + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", "dev": true, "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/eslintrc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.3.tgz", - "integrity": "sha512-+5gy6OQfk+xx3q0d6jGZZC3f3KzAkXc/IanVxd1is/VIIziRqqt3ongQz0FiTUXqTk0c7aDB3OaFuKnuSoJicQ==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.5.2", + "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -2325,22 +2327,22 @@ } }, "node_modules/@eslint/js": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.43.0.tgz", - "integrity": "sha512-s2UHCoiXfxMvmfzqoN+vrQ84ahUSYde9qNO1MdxmoEhyHWsfmwOpFlwYV+ePJEVc7gFnATGUi376WowX1N7tFg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", - "integrity": "sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==", + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "dev": true, "dependencies": { - "@humanwhocodes/object-schema": "^1.2.1", - "debug": "^4.1.1", + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", "minimatch": "^3.0.5" }, "engines": { @@ -2361,11 +2363,55 @@ } }, "node_modules/@humanwhocodes/object-schema": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz", - "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@jest/schemas": { "version": "29.6.0", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.0.tgz", @@ -2485,6 +2531,37 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, + "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 +2626,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.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", @@ -2585,16 +2665,22 @@ "@types/node": "*" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, "node_modules/@vitejs/plugin-vue": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.2.3.tgz", - "integrity": "sha512-R6JDUfiZbJA9cMiguQ7jxALsgiprjBeHL5ikpXfJCH62pPHtI+JdJ5xWj6Ev73yXSlYl86+blXn1kZHQ7uElxw==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", "dev": true, "engines": { "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.0.0", + "vite": "^4.0.0 || ^5.0.0", "vue": "^3.2.25" } }, @@ -2694,133 +2780,108 @@ } }, "node_modules/@vue/compiler-core": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.4.tgz", - "integrity": "sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.27.tgz", + "integrity": "sha512-E+RyqY24KnyDXsCuQrI+mlcdW3ALND6U7Gqa/+bVwbcpcR3BRRIckFoz7Qyd4TTlnugtwuI7YgjbvsLmxb+yvg==", "dependencies": { - "@babel/parser": "^7.21.3", - "@vue/shared": "3.3.4", + "@babel/parser": "^7.24.4", + "@vue/shared": "3.4.27", + "entities": "^4.5.0", "estree-walker": "^2.0.2", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-dom": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.4.tgz", - "integrity": "sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.27.tgz", + "integrity": "sha512-kUTvochG/oVgE1w5ViSr3KUBh9X7CWirebA3bezTbB5ZKBQZwR2Mwj9uoSKRMFcz4gSMzzLXBPD6KpCLb9nvWw==", "dependencies": { - "@vue/compiler-core": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-core": "3.4.27", + "@vue/shared": "3.4.27" } }, "node_modules/@vue/compiler-sfc": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.4.tgz", - "integrity": "sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==", - "dependencies": { - "@babel/parser": "^7.20.15", - "@vue/compiler-core": "3.3.4", - "@vue/compiler-dom": "3.3.4", - "@vue/compiler-ssr": "3.3.4", - "@vue/reactivity-transform": "3.3.4", - "@vue/shared": "3.3.4", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.27.tgz", + "integrity": "sha512-nDwntUEADssW8e0rrmE0+OrONwmRlegDA1pD6QhVeXxjIytV03yDqTey9SBDiALsvAd5U4ZrEKbMyVXhX6mCGA==", + "dependencies": { + "@babel/parser": "^7.24.4", + "@vue/compiler-core": "3.4.27", + "@vue/compiler-dom": "3.4.27", + "@vue/compiler-ssr": "3.4.27", + "@vue/shared": "3.4.27", "estree-walker": "^2.0.2", - "magic-string": "^0.30.0", - "postcss": "^8.1.10", - "source-map-js": "^1.0.2" + "magic-string": "^0.30.10", + "postcss": "^8.4.38", + "source-map-js": "^1.2.0" } }, "node_modules/@vue/compiler-ssr": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.4.tgz", - "integrity": "sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.27.tgz", + "integrity": "sha512-CVRzSJIltzMG5FcidsW0jKNQnNRYC8bT21VegyMMtHmhW3UOI7knmUehzswXLrExDLE6lQCZdrhD4ogI7c+vuw==", "dependencies": { - "@vue/compiler-dom": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-dom": "3.4.27", + "@vue/shared": "3.4.27" } }, "node_modules/@vue/devtools-api": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz", - "integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==" + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.1.tgz", + "integrity": "sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA==" }, "node_modules/@vue/reactivity": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.4.tgz", - "integrity": "sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.27.tgz", + "integrity": "sha512-kK0g4NknW6JX2yySLpsm2jlunZJl2/RJGZ0H9ddHdfBVHcNzxmQ0sS0b09ipmBoQpY8JM2KmUw+a6sO8Zo+zIA==", "dependencies": { - "@vue/shared": "3.3.4" - } - }, - "node_modules/@vue/reactivity-transform": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.4.tgz", - "integrity": "sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==", - "dependencies": { - "@babel/parser": "^7.20.15", - "@vue/compiler-core": "3.3.4", - "@vue/shared": "3.3.4", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.0" + "@vue/shared": "3.4.27" } }, "node_modules/@vue/runtime-core": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.4.tgz", - "integrity": "sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.27.tgz", + "integrity": "sha512-7aYA9GEbOOdviqVvcuweTLe5Za4qBZkUY7SvET6vE8kyypxVgaT1ixHLg4urtOlrApdgcdgHoTZCUuTGap/5WA==", "dependencies": { - "@vue/reactivity": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/reactivity": "3.4.27", + "@vue/shared": "3.4.27" } }, "node_modules/@vue/runtime-dom": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.4.tgz", - "integrity": "sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.27.tgz", + "integrity": "sha512-ScOmP70/3NPM+TW9hvVAz6VWWtZJqkbdf7w6ySsws+EsqtHvkhxaWLecrTorFxsawelM5Ys9FnDEMt6BPBDS0Q==", "dependencies": { - "@vue/runtime-core": "3.3.4", - "@vue/shared": "3.3.4", - "csstype": "^3.1.1" + "@vue/runtime-core": "3.4.27", + "@vue/shared": "3.4.27", + "csstype": "^3.1.3" } }, "node_modules/@vue/server-renderer": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.4.tgz", - "integrity": "sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.27.tgz", + "integrity": "sha512-dlAMEuvmeA3rJsOMJ2J1kXU7o7pOxgsNHVr9K8hB3ImIkSuBrIdy0vF66h8gf8Tuinf1TK3mPAz2+2sqyf3KzA==", "dependencies": { - "@vue/compiler-ssr": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-ssr": "3.4.27", + "@vue/shared": "3.4.27" }, "peerDependencies": { - "vue": "3.3.4" + "vue": "3.4.27" } }, "node_modules/@vue/shared": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.4.tgz", - "integrity": "sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==" + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.27.tgz", + "integrity": "sha512-DL3NmY2OFlqmYYrzp39yi3LDkKxa5vZVwxWdQ3rG0ekuWscHraeIbnI8t+aZK7qhYqEqWKTUdijadunb9pnrgA==" }, "node_modules/@vue/test-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.0.tgz", - "integrity": "sha512-BKB9aj1yky63/I3IwSr1FjUeHYsKXI7D6S9F378AHt7a5vC0dLkOBtSsFXoRGC/7BfHmiB9HRhT+i9xrUHoAKw==", + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", "dev": true, "dependencies": { - "js-beautify": "1.14.6", - "vue-component-type-helpers": "1.6.5" - }, - "peerDependencies": { - "@vue/compiler-dom": "^3.0.1", - "@vue/server-renderer": "^3.0.1", - "vue": "^3.0.1" - }, - "peerDependenciesMeta": { - "@vue/compiler-dom": { - "optional": true - }, - "@vue/server-renderer": { - "optional": true - } + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" } }, "node_modules/abab": { @@ -2830,10 +2891,13 @@ "dev": true }, "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "dev": true + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, "node_modules/acorn": { "version": "8.9.0", @@ -3080,12 +3144,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -3562,9 +3626,9 @@ } }, "node_modules/csstype": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", - "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/data-urls": { "version": "4.0.0", @@ -3783,50 +3847,67 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, "node_modules/editorconfig": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", - "integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz", + "integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==", "dev": true, "dependencies": { - "commander": "^2.19.0", - "lru-cache": "^4.1.5", - "semver": "^5.6.0", - "sigmund": "^1.0.1" + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "9.0.1", + "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" } }, - "node_modules/editorconfig/node_modules/lru-cache": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", - "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "dev": true, "dependencies": { - "pseudomap": "^1.0.2", - "yallist": "^2.1.2" + "balanced-match": "^1.0.0" } }, - "node_modules/editorconfig/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "dev": true, - "bin": { - "semver": "bin/semver" + "engines": { + "node": ">=14" } }, - "node_modules/editorconfig/node_modules/yallist": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", - "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", - "dev": true + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz", + "integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/ejs": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", - "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "dependencies": { "jake": "^10.8.5" @@ -3844,6 +3925,12 @@ "integrity": "sha512-kKiHnbrHME7z8E6AYaw0ehyxY5+hdaRmeUbjBO22LZMdqTYCO29EvF0T1cQ3pJ1RN5fyMcHl1Lmcsdt9WWJpJQ==", "dev": true }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -3857,7 +3944,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "engines": { "node": ">=0.12" }, @@ -4013,27 +4099,28 @@ } }, "node_modules/eslint": { - "version": "8.43.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.43.0.tgz", - "integrity": "sha512-aaCpf2JqqKesMFGgmRPessmVKjcGXqdlAYLLC3THM8t5nBRZRQ+st5WM/hoJXkdioEXLLbXgclUpM0TXo5HX5Q==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.4.0", - "@eslint/eslintrc": "^2.0.3", - "@eslint/js": "8.43.0", - "@humanwhocodes/config-array": "^0.11.10", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.0", - "eslint-visitor-keys": "^3.4.1", - "espree": "^9.5.2", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -4043,7 +4130,6 @@ "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", - "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", @@ -4053,9 +4139,8 @@ "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { @@ -4069,30 +4154,31 @@ } }, "node_modules/eslint-plugin-vue": { - "version": "9.15.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.15.1.tgz", - "integrity": "sha512-CJE/oZOslvmAR9hf8SClTdQ9JLweghT6JCBQNrT2Iel1uVw0W0OLJxzvPd6CxmABKCvLrtyDnqGV37O7KQv6+A==", + "version": "9.26.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.26.0.tgz", + "integrity": "sha512-eTvlxXgd4ijE1cdur850G6KalZqk65k1JKoOI2d1kT3hr8sPD07j1q98FRFdNnpxBELGPWxZmInxeHGF/GxtqQ==", "dev": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.3.0", + "@eslint-community/eslint-utils": "^4.4.0", + "globals": "^13.24.0", "natural-compare": "^1.4.0", - "nth-check": "^2.0.1", - "postcss-selector-parser": "^6.0.9", - "semver": "^7.3.5", - "vue-eslint-parser": "^9.3.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.0", + "vue-eslint-parser": "^9.4.2", "xml-name-validator": "^4.0.0" }, "engines": { "node": "^14.17.0 || >=16.0.0" }, "peerDependencies": { - "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0" + "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" } }, "node_modules/eslint-scope": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", - "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "dependencies": { "esrecurse": "^4.3.0", @@ -4106,9 +4192,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.1.tgz", - "integrity": "sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4118,12 +4204,12 @@ } }, "node_modules/espree": { - "version": "9.5.2", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.2.tgz", - "integrity": "sha512-7OASN1Wma5fum5SrNhFMAMJxOUAbhyfQ8dQ//PJaJbNw0URTPWqIghHWt1MmAANKhHZIYOHruW4Kw4ruUWOdGw==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "dependencies": { - "acorn": "^8.8.0", + "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" }, @@ -4220,9 +4306,9 @@ "dev": true }, "node_modules/fast-glob": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.0.tgz", - "integrity": "sha512-ChDuvbOypPuNjO8yIDf36x7BlZX1smcUMTTcyoIjycexOxd6DFsKsg21qVBzEmr3G7fUKIRy2/psii+CIUt7FA==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -4278,9 +4364,9 @@ } }, "node_modules/feather-icons": { - "version": "4.29.0", - "resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.0.tgz", - "integrity": "sha512-Y7VqN9FYb8KdaSF0qD1081HCkm0v4Eq/fpfQYQnubpqi0hXx14k+gF9iqtRys1SIcTEi97xDi/fmsPFZ8xo0GQ==", + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz", + "integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==", "dependencies": { "classnames": "^2.2.5", "core-js": "^3.1.3" @@ -4329,9 +4415,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -4407,6 +4493,22 @@ "is-callable": "^1.1.3" } }, + "node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -4607,9 +4709,9 @@ } }, "node_modules/globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -4859,9 +4961,9 @@ ] }, "node_modules/ignore": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", - "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", "dev": true, "engines": { "node": ">= 4" @@ -5063,6 +5165,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -5287,6 +5398,24 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/jackspeak": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", + "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.8.7", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", @@ -5320,15 +5449,16 @@ } }, "node_modules/js-beautify": { - "version": "1.14.6", - "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.14.6.tgz", - "integrity": "sha512-GfofQY5zDp+cuHc+gsEXKPpNw2KbPddreEo35O6jT6i0RVK6LhsoYBhq5TvK4/n74wnA0QbK8gGd+jUZwTMKJw==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.1.tgz", + "integrity": "sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA==", "dev": true, "dependencies": { "config-chain": "^1.1.13", - "editorconfig": "^0.15.3", - "glob": "^8.0.3", - "nopt": "^6.0.0" + "editorconfig": "^1.0.4", + "glob": "^10.3.3", + "js-cookie": "^3.0.5", + "nopt": "^7.2.0" }, "bin": { "css-beautify": "js/bin/css-beautify.js", @@ -5336,7 +5466,7 @@ "js-beautify": "js/bin/js-beautify.js" }, "engines": { - "node": ">=10" + "node": ">=14" } }, "node_modules/js-beautify/node_modules/brace-expansion": { @@ -5349,34 +5479,49 @@ } }, "node_modules/js-beautify/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "version": "10.3.15", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.15.tgz", + "integrity": "sha512-0c6RlJt1TICLyvJYIApxb8GsXoai0KUP7AxKKAtsYXdgJR1mGEUa7DgwShbdk1nly0PYoZj01xd4hzbq3fsjpw==", "dev": true, "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" + "foreground-child": "^3.1.0", + "jackspeak": "^2.3.6", + "minimatch": "^9.0.1", + "minipass": "^7.0.4", + "path-scurry": "^1.11.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=12" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/js-beautify/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=10" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "engines": { + "node": ">=14" } }, "node_modules/js-tokens": { @@ -5670,14 +5815,11 @@ } }, "node_modules/magic-string": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", - "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "version": "0.30.10", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", + "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" + "@jridgewell/sourcemap-codec": "^1.4.15" } }, "node_modules/map-obj": { @@ -5820,6 +5962,15 @@ "node": ">= 6" } }, + "node_modules/minipass": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.1.tgz", + "integrity": "sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==", + "dev": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -5916,18 +6067,18 @@ "dev": true }, "node_modules/nopt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", - "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", "dev": true, "dependencies": { - "abbrev": "^1.0.0" + "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, "node_modules/normalize-package-data": { @@ -6152,6 +6303,31 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", + "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/pathe": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz", @@ -6265,10 +6441,40 @@ "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", - "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "funding": [ { "type": "opencollective", @@ -6286,16 +6492,16 @@ "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/postcss-selector-parser": { - "version": "6.0.13", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", - "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "version": "6.0.16", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.16.tgz", + "integrity": "sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -6329,9 +6535,9 @@ } }, "node_modules/pretty-bytes": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.0.tgz", - "integrity": "sha512-Rk753HI8f4uivXi4ZCIYdhmG1V+WKzvRMg/X+M42a6t7D07RcmopXJMDNk6N++7Bl75URRGsb40ruvg7Hcp2wQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", "dev": true, "engines": { "node": "^14.13.1 || >=16.0.0" @@ -6387,12 +6593,6 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, - "node_modules/pseudomap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", - "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", - "dev": true - }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -6950,13 +7150,10 @@ } }, "node_modules/semver": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", - "integrity": "sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==", + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, "bin": { "semver": "bin/semver.js" }, @@ -7014,11 +7211,17 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, - "node_modules/sigmund": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", - "integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==", - "dev": true + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/slash": { "version": "3.0.0", @@ -7042,9 +7245,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "engines": { "node": ">=0.10.0" } @@ -7154,6 +7357,71 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.8.tgz", @@ -7244,6 +7512,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -7594,6 +7875,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", @@ -7731,9 +8018,9 @@ } }, "node_modules/vite": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", - "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", + "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", "dev": true, "dependencies": { "esbuild": "^0.18.10", @@ -7809,14 +8096,14 @@ } }, "node_modules/vite-plugin-pwa": { - "version": "0.16.4", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.4.tgz", - "integrity": "sha512-lmwHFIs9zI2H9bXJld/zVTbCqCQHZ9WrpyDMqosICDV0FVnCJwniX1NMDB79HGTIZzOQkY4gSZaVTJTw6maz/Q==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.16.7.tgz", + "integrity": "sha512-4WMA5unuKlHs+koNoykeuCfTcqEGbiTRr8sVYUQMhc6tWxZpSRnv9Ojk4LKmqVhoPGHfBVCdGaMo8t9Qidkc1Q==", "dev": true, "dependencies": { "debug": "^4.3.4", - "fast-glob": "^3.2.12", - "pretty-bytes": "^6.0.0", + "fast-glob": "^3.3.1", + "pretty-bytes": "^6.1.1", "workbox-build": "^7.0.0", "workbox-window": "^7.0.0" }, @@ -7827,7 +8114,7 @@ "url": "https://github.com/sponsors/antfu" }, "peerDependencies": { - "vite": "^3.1.0 || ^4.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", "workbox-build": "^7.0.0", "workbox-window": "^7.0.0" } @@ -7910,27 +8197,35 @@ } }, "node_modules/vue": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.3.4.tgz", - "integrity": "sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==", + "version": "3.4.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.27.tgz", + "integrity": "sha512-8s/56uK6r01r1icG/aEOHqyMVxd1bkYcSe9j8HcKtr/xTOFWvnzIVTehNW+5Yt89f+DLBe4A569pnZLS5HzAMA==", "dependencies": { - "@vue/compiler-dom": "3.3.4", - "@vue/compiler-sfc": "3.3.4", - "@vue/runtime-dom": "3.3.4", - "@vue/server-renderer": "3.3.4", - "@vue/shared": "3.3.4" + "@vue/compiler-dom": "3.4.27", + "@vue/compiler-sfc": "3.4.27", + "@vue/runtime-dom": "3.4.27", + "@vue/server-renderer": "3.4.27", + "@vue/shared": "3.4.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/vue-component-type-helpers": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-1.6.5.tgz", - "integrity": "sha512-iGdlqtajmiqed8ptURKPJ/Olz0/mwripVZszg6tygfZSIL9kYFPJTNY6+Q6OjWGznl2L06vxG5HvNvAnWrnzbg==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.0.19.tgz", + "integrity": "sha512-cN3f1aTxxKo4lzNeQAkVopswuImUrb5Iurll9Gaw5cqpnbTAxtEMM1mgi6ou4X79OCyqYv1U1mzBHJkzmiK82w==", "dev": true }, "node_modules/vue-eslint-parser": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.3.1.tgz", - "integrity": "sha512-Clr85iD2XFZ3lJ52/ppmUDG/spxQu6+MAeHXjjyI4I1NUYZ9xmenQp4N0oaHJhrA8OOxltCVxMRfANGa70vU0g==", + "version": "9.4.2", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.2.tgz", + "integrity": "sha512-Ry9oiGmCAK91HrKMtCrKFWmSFWvYkpGglCeFAIqDdr9zdXmMMpJOmUJS7WWsW7fX81h6mwHmUZCQQ1E0PkSwYQ==", "dev": true, "dependencies": { "debug": "^4.3.4", @@ -7961,11 +8256,11 @@ } }, "node_modules/vue-router": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.2.tgz", - "integrity": "sha512-cChBPPmAflgBGmy3tBsjeoe3f3VOSG6naKyY5pjtrqLGbNEXdzCigFUHgBvp9e3ysAtFtEx7OLqcSDh/1Cq2TQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.2.tgz", + "integrity": "sha512-hKQJ1vDAZ5LVkKEnHhmm1f9pMiWIBNGF5AwU67PdH7TyXCj/a4hTccuUuYCAMgJK6rO/NVYtQIEN3yL8CECa7Q==", "dependencies": { - "@vue/devtools-api": "^6.5.0" + "@vue/devtools-api": "^6.5.1" }, "funding": { "url": "https://github.com/sponsors/posva" @@ -8457,6 +8752,100 @@ "workbox-core": "7.0.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8464,9 +8853,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json @@ -1,30 +1,34 @@ { "name": "running-tools", - "version": "1.3.0", + "version": "1.4.0", "description": "A collection of tools for runners and their coaches that calculate splits, predict race times, convert units, and more", "private": true, + "type": "module", "scripts": { "dev": "vite", "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", - "vue": "^3.3.4", + "feather-icons": "^4.29.2", + "vue": "^3.4.27", "vue-feather": "^2.0.0", - "vue-router": "^4.2.2" + "vue-router": "^4.3.2" }, "devDependencies": { - "@vitejs/plugin-vue": "^4.2.3", - "@vue/test-utils": "^2.4.0", - "eslint": "^8.39.0", - "eslint-plugin-vue": "^9.11.0", + "@playwright/test": "^1.44.0", + "@types/node": "^20.12.12", + "@vitejs/plugin-vue": "^4.6.2", + "@vue/test-utils": "^2.4.6", + "eslint": "^8.57.0", + "eslint-plugin-vue": "^9.26.0", "jsdom": "^22.1.0", "pwa-asset-generator": "^6.3.1", - "vite": "^4.3.9", - "vite-plugin-pwa": "^0.16.4", + "vite": "^4.5.3", + "vite-plugin-pwa": "^0.16.7", "vitest": "^0.32.4" } } diff --git a/playwright.config.js b/playwright.config.js @@ -0,0 +1,54 @@ +// @ts-check +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default 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/scripts/deploy.sh b/scripts/deploy.sh diff --git a/src/App.vue b/src/App.vue @@ -55,10 +55,10 @@ h1 { #route-content { margin: 1em; } -@media only screen and (max-width: 320px) { +@media only screen and (max-width: 450px) { /* adjust title size to fit small devices */ h1 { - font-size: 8vw; + font-size: 7vw; } } </style> diff --git a/src/assets/global.css b/src/assets/global.css @@ -15,18 +15,14 @@ input, select, button { button { cursor: pointer; } -.link, .link:focus, .link:active, .link:hover { - border: none; - background: none; -} -a, .link { +a { text-decoration: none; } -a:focus, .link:focus { +a:focus { text-decoration: underline; } @media (hover: hover) { - a:hover, .link:hover { + a:hover { text-decoration: underline; } } @@ -119,7 +115,7 @@ button, input, select, tr { dialog { border: 2px solid var(--background5); } -a, .link { +a { color: var(--link); } input:invalid:not(:focus) { diff --git a/src/assets/target-calculator.css b/src/assets/target-calculator.css @@ -10,7 +10,7 @@ h2 { font-size: 1.3em; margin-bottom: 0.2em; } -* + h2, summary h2 { +h2:not(:first-child), summary h2 { margin-top: 0.5em; } @@ -18,9 +18,6 @@ h2 { .input>* { margin-bottom: 5px; /* adds space between wrapped lines */ } -.input select { - margin-left: 5px; -} /* collapsable sections */ summary { @@ -39,6 +36,8 @@ details > * { /* calculator output */ .output { min-width: 300px; + max-width: 100%; + overflow: auto; } @media only screen and (max-width: 500px) { .output { diff --git a/src/components/DecimalInput.vue b/src/components/DecimalInput.vue @@ -1,110 +1,101 @@ <template> - <input ref="input" type="number" step="any" required @blur="onblur" v-model="stringValue"> + <input ref="inputElement" type="number" step="any" required @blur="onblur" v-model="stringValue"> </template> -<script> -import formatUtils from '@/utils/format'; +<script setup> +import { ref, watch } from 'vue'; +import { formatNumber } from '@/utils/format'; -export default { - name: 'DecimalInput', +/** + * The component value + */ +const model = defineModel({ + type: Number, + default: 0, +}); - props: { - /** - * The input value - */ - modelValue: { - type: Number, - default: 0, - }, - - /** - * The number of digits to show before the decimal point - */ - padding: { - type: Number, - default: 0, - validator(value) { - return value >= 0; - }, +const props = defineProps({ + /** + * The number of digits to show before the decimal point + */ + padding: { + type: Number, + default: 0, + validator(value) { + return value >= 0; }, + }, - /** - * The number of digits to show after the decimal point - */ - digits: { - type: Number, - default: 1, - validator(value) { - return value > 0; - }, + /** + * The number of digits to show after the decimal point + */ + digits: { + type: Number, + default: 1, + validator(value) { + return value > 0; }, }, +}); - data() { - return { - /** - * The internal float value - */ - internalValue: this.modelValue, +/** + * The internal float value + */ +const internalValue = ref(model.value); - /** - * The raw string value (empty if input is currently invalid) - */ - stringValue: this.format(this.modelValue), - }; - }, +/** + * The raw string value (empty if input is currently invalid) + */ +const stringValue = ref(format(model.value)); - watch: { - /** - * Update the component value when the modelValue prop changes - * @param {Number} newValue The new prop value - */ - modelValue(newValue) { - if (newValue !== this.internalValue) { - this.internalValue = newValue; - this.stringValue = this.format(this.internalValue); - } - }, +/** + * The input element + */ +const inputElement = ref(null); - /** - * Emit the input event when the internal value changes - * @param {Number} newValue The new internal float value - */ - internalValue(newValue) { - this.$emit('update:modelValue', newValue); - }, +/* + * Update the internal value when the component value changes + */ +watch(model, (newValue) => { + if (newValue !== internalValue.value) { + internalValue.value = newValue; + stringValue.value = format(internalValue.value); + } +}); - /** - * Update the float value when the raw string value changes - * @param {Number} newValue The new raw string value - */ - stringValue(newValue) { - if (this.$refs.input.validity.valid) { - this.internalValue = Number(newValue); - } - }, - }, +/** + * Update the component value when the internal value changes + */ +watch(internalValue, (newValue) => { + model.value = newValue; +}); - methods: { - /** - * Reformat display value if not invalid - */ - onblur() { - if (this.$refs.input.validity.valid) { - this.stringValue = this.format(this.internalValue); - } - }, +/** + * Update the internal value when the raw string value changes + */ +watch(stringValue, (newValue) => { + if (inputElement.value.validity.valid) { + internalValue.value = Number(newValue); + } +}); - /** - * Format a decimal as a string - * @param {Number} value The decimal - * @returns {String} The formated string - */ - format(value) { - return formatUtils.formatNumber(value, this.padding, this.digits, true); - }, - }, -}; +/** + * Reformat display value if not invalid + */ +function onblur() { + if (inputElement.value.validity.valid) { + stringValue.value = format(internalValue.value); + } +} + +/** + * Format a decimal as a string + * @param {Number} value The decimal + * @returns {String} The formated string + */ +function format(value) { + return formatNumber(value, props.padding, props.digits, true); +} </script> <style scoped> diff --git a/src/components/DoubleOutputTable.vue b/src/components/DoubleOutputTable.vue @@ -0,0 +1,104 @@ +<template> + <div class="double-target-table"> + <table class="results"> + <thead> + <tr> + <th v-for="(col, x) in results[0]" :key="x"> + {{ col }} + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(row, y) in results.slice(1)" :key="y"> + <td v-for="(col, x) in row" :key="x"> + {{ col }} + </td> + </tr> + + <tr v-if="results.length === 1" class="empty-message"> + <td colspan="4"> + No inputs were specified. + </td> + </tr> + </tbody> + </table> + </div> +</template> + +<script setup> +import { computed } from 'vue'; +import { formatDuration, formatNumber } from '@/utils/format'; +import { DISTANCE_UNITS } from '@/utils/units'; + +const props = defineProps({ + /** + * The method that generates the target table rows + */ + calculateResult: { + type: Function, + required: true, + }, + + /** + * The target set + */ + targets: { + type: Array, + default: () => [], + }, + + /** + * The set of input times + */ + inputTimes: { + type: Array, + default: () => [], + }, + + /** + * The input distance + */ + inputDistance: { + type: Object, + default: () => ({ + distanceValue: 5, + distanceUnit: 'kilometers', + }), + } +}); + +/** + * The target table results + */ +const results = computed(() => { + // Calculate results + const results = [[ + formatNumber(props.inputDistance.distanceValue, 0, 2, false) + ' ' + + DISTANCE_UNITS[props.inputDistance.distanceUnit].symbol + ]]; + + props.inputTimes.forEach((input, y) => { + let row = [formatDuration(input, 3, 2, false)]; + + props.targets.forEach(target => { + let result = props.calculateResult({ ...props.inputDistance, time: input }, target); + + if (y === 0) { + results[0].push(result[result.result === 'key' ? 'value' : 'key']); + } + + row.push(result[result.result]); + }); + results.push(row); + }); + return results; +}); +</script> + +<style scoped> +table th, table td { + /* Add more space between table cells */ + padding: 0.2em 0.5em; +} +</style> diff --git a/src/components/IntegerInput.vue b/src/components/IntegerInput.vue @@ -1,97 +1,89 @@ <template> - <input ref="input" type="number" step="1" required @blur="onblur" v-model="stringValue"> + <input ref="inputElement" type="number" step="1" required @blur="onblur" v-model="stringValue"> </template> -<script> -export default { - name: 'IntegerInput', +<script setup> +import { ref, watch } from 'vue'; - props: { - /** - * The input value - */ - modelValue: { - type: Number, - default: 0, - }, +/** + * The component value + */ +const model = defineModel({ + type: Number, + default: 0, +}); - /** - * The number of digits to show before the decimal point - */ - padding: { - type: Number, - default: 0, - validator(value) { - return value >= 0; - }, +const props = defineProps({ + /** + * The number of digits to show before the decimal point + */ + padding: { + type: Number, + default: 0, + validator(value) { + return value >= 0; }, }, +}); - data() { - return { - /** - * The internal integer value - */ - internalValue: this.modelValue, +/** + * The internal integer value + */ +const internalValue = ref(model.value); - /** - * The raw string value (empty if input is currently invalid) - */ - stringValue: this.format(this.modelValue), - }; - }, +/** + * The raw string value (empty if input is currently invalid) + */ +const stringValue = ref(format(model.value)); - watch: { - /** - * Update the component value when the modelValue prop changes - * @param {Number} newValue The new prop value - */ - modelValue(newValue) { - if (newValue !== this.internalValue) { - this.internalValue = newValue; - this.stringValue = this.format(this.internalValue); - } - }, +/** + * The input element + */ +const inputElement = ref(null); - /** - * Emit the input event when the internal value changes - * @param {Number} newValue The new internal integer value - */ - internalValue(newValue) { - this.$emit('update:modelValue', newValue); - }, +/** + * Update the internal value when the component value changes + */ +watch(model, (newValue) => { + if (newValue !== internalValue.value) { + internalValue.value = newValue; + stringValue.value = format(internalValue.value); + } +}); - /** - * Update the integer value when the raw string value changes - * @param {Number} newValue The new raw string value - */ - stringValue(newValue) { - if (this.$refs.input.validity.valid) { - this.internalValue = Number(newValue); - } - }, - }, +/** + * Update the component value when the internal value changes + */ +watch(internalValue, (newValue) => { + model.value = newValue; +}); - methods: { - /** - * Reformat display value if not invalid - */ - onblur() { - if (this.$refs.input.validity.valid) { - this.stringValue = this.format(this.internalValue); - } - }, +/** + * Update the internal value when the raw string value changes + */ +watch(stringValue, (newValue) => { + if (inputElement.value.validity.valid) { + internalValue.value = Number(newValue); + } +}); - /** - * Format an integer as a string - * @param {Number} value The integer - * @returns {String} The formated string - */ - format(value) { - return value.toString().padStart(this.padding, '0'); - }, - }, -}; +/** + * Reformat display value if not invalid + */ +function onblur() { + if (inputElement.value.validity.valid) { + stringValue.value = format(internalValue.value); + } +} + +/** + * Format an integer as a string + * @param {Number} value The integer + * @returns {String} The formated string + */ +function format(value) { + return value.toString().padStart(props.padding, '0'); +} </script> <style scoped> diff --git a/src/components/PaceInput.vue b/src/components/PaceInput.vue @@ -0,0 +1,57 @@ +<template> + <div class="pace-input"> + <div> + Distance: + <decimal-input v-model="model.distanceValue" + :aria-label="label + ' distance value'" :min="0" :digits="2"/> + <select v-model="model.distanceUnit" :aria-label="label + ' distance unit'"> + <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key"> + {{ value.name }} + </option> + </select> + </div> + <div> + Time: + <time-input v-model="model.time" :label="label + ' duration'"/> + </div> + </div> +</template> + +<script setup> +import DecimalInput from '@/components/DecimalInput.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import { DISTANCE_UNITS } from '@/utils/units'; + +/** + * The component value + */ +const model = defineModel({ + type: Object, + default: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, +}); + +defineProps({ + /** + * The prefix for each field's aria-label + */ + label: { + type: String, + default: 'Input', + }, +}); + +</script> + +<style scoped> +.pace-input div + div { + margin-top: 5px; +} +.pace-input select { + margin-left: 5px; +} +</style> diff --git a/src/components/RaceOptions.vue b/src/components/RaceOptions.vue @@ -0,0 +1,30 @@ +<template> + <div> + Prediction Model: + <select v-model="model.model" aria-label="Prediction model"> + <option value="AverageModel">Average</option> + <option value="PurdyPointsModel">Purdy Points Model</option> + <option value="VO2MaxModel">V&#775;O&#8322; Max Model</option> + <option value="CameronModel">Cameron's Model</option> + <option value="RiegelModel">Riegel's Model</option> + </select> + </div> + <div> + Riegel Exponent: + <decimal-input v-model="model.riegelExponent" aria-label="Riegel exponent" :min="1" :max="1.3" + :digits="2" :step="0.01"/> + (default: 1.06) + </div> +</template> + +<script setup> +import DecimalInput from '@/components/DecimalInput.vue'; + +const model = defineModel({ + type: Object, + default: { + model: 'AverageModel', + riegelExponent: 1.06, + }, +}); +</script> diff --git a/src/components/SimpleTargetTable.vue b/src/components/SimpleTargetTable.vue @@ -1,144 +0,0 @@ -<template> - <div class="simple-target-table"> - <table class="results"> - <thead> - <tr> - <th>Distance</th> - - <th>Time</th> - - <th v-if="showPace">Pace</th> - </tr> - </thead> - - <tbody> - <tr v-for="(item, index) in results" :key="index"> - <td :class="item.result === 'distance' ? 'result' : ''"> - {{ formatNumber(item.distanceValue, 0, 2, item.result === 'distance') }} - {{ distanceUnits[item.distanceUnit].symbol }} - </td> - - <td :class="item.result === 'time' ? 'result' : ''"> - {{ formatDuration(item.time, 3, 2, item.result === 'time') }} - </td> - - <td v-if="showPace"> - {{ formatDuration(getPace(item), 3, 0, true) }} - / {{ distanceUnits[getDefaultDistanceUnit(defaultUnitSystem)].symbol }} - </td> - </tr> - - <tr v-if="results.length === 0" class="empty-message"> - <td colspan="4"> - There aren't any targets in this set yet. - </td> - </tr> - </tbody> - </table> - </div> -</template> - -<script> -import formatUtils from '@/utils/format'; -import unitUtils from '@/utils/units'; - -export default { - name: 'SimpleTargetTable', - - props: { - /** - * The method that generates the target table rows - */ - calculateResult: { - type: Function, - required: true, - }, - - /** - * The target set - */ - targets: { - type: Array, - default: () => [], - }, - - /** - * Whether to show result paces - */ - showPace: { - type: Boolean, - default: false, - }, - - /** - * The unit system to use when showing result paces - */ - defaultUnitSystem: { - type: String, - default: 'metric', - }, - }, - - data() { - return { - /** - * The distance units - */ - distanceUnits: unitUtils.DISTANCE_UNITS, - - /** - * The formatDuration method - */ - formatDuration: formatUtils.formatDuration, - - /** - * The formatNumber method - */ - formatNumber: formatUtils.formatNumber, - - /** - * The getDefaultDistanceUnit method - */ - getDefaultDistanceUnit: unitUtils.getDefaultDistanceUnit, - }; - }, - - computed: { - /** - * The target table results - */ - results() { - // Calculate results - const result = []; - this.targets.forEach((row) => { - // Add result - result.push(this.calculateResult(row)); - }); - - // Sort results by time - result.sort((a, b) => a.time - b.time); - - // Return results - return result; - }, - }, - - methods: { - /** - * Get the pace of a result - * @param {Object} result The result - */ - getPace(result) { - return result.time / unitUtils.convertDistance(result.distanceValue, result.distanceUnit, - unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem)); - }, - }, -}; -</script> - -<style scoped> -/* target table */ -.results .result { - font-weight: bold; -} -</style> diff --git a/src/components/SingleOutputTable.vue b/src/components/SingleOutputTable.vue @@ -0,0 +1,92 @@ +<template> + <div class="simple-target-table"> + <table class="results"> + <thead> + <tr> + <th>Distance</th> + + <th>Time</th> + + <th v-if="showPace">Pace</th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in results" :key="index"> + <td :class="item.result === 'key' ? 'result' : ''"> + {{ item.key }} + </td> + + <td :class="item.result === 'value' ? 'result' : ''"> + {{ item.value }} + </td> + + <td v-if="showPace"> + {{ item.pace }} + </td> + </tr> + + <tr v-if="results.length === 0" class="empty-message"> + <td colspan="4"> + There aren't any targets in this set yet. + </td> + </tr> + </tbody> + </table> + </div> +</template> + +<script setup> +import { computed } from 'vue'; + +const props = defineProps({ + /** + * The method that generates the target table rows + */ + calculateResult: { + type: Function, + required: true, + }, + + /** + * The target set + */ + targets: { + type: Array, + default: () => [], + }, + + /** + * Whether to show result paces + */ + showPace: { + type: Boolean, + default: false, + }, +}); + +/** + * The target table results + */ +const results = computed(() => { + // Calculate results + const result = []; + props.targets.forEach((row) => { + // Add result + result.push(props.calculateResult(row)); + }); + + // Sort results + result.sort((a, b) => a.sort - b.sort); + + // Return results + return result; +}); +</script> + +<style scoped> +/* target table */ +.results .result { + font-weight: bold; +} +</style> diff --git a/src/components/SplitOutputTable.vue b/src/components/SplitOutputTable.vue @@ -0,0 +1,127 @@ +<template> + <table class="split-output-table"> + <thead> + <tr> + <th> + <span>Distance</span> + <span class="mobile-abbreviation">Dist.</span> + </th> + + <th>Time</th> + + <th>Split</th> + + <th>Pace</th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in results" :key="index"> + <td> + {{ formatNumber(item.distanceValue, 0, 2, false) }} + {{ DISTANCE_UNITS[item.distanceUnit].symbol }} + </td> + + <td> + {{ formatDuration(item.time, 3, 2, true) }} + </td> + + <td> + <time-input v-model="targets[index].splitTime" label="Split duration" :showHours="false"/> + </td> + + <td> + {{ formatDuration(item.pace, 3, 0, true) }} + / {{ DISTANCE_UNITS[getDefaultDistanceUnit(defaultUnitSystem)] + .symbol }} + </td> + </tr> + + <tr v-if="results.length === 0" class="empty-message"> + <td colspan="5"> + There aren't any targets in this set yet. + </td> + </tr> + </tbody> + </table> +</template> + +<script setup> +import { computed } from 'vue'; + +import { formatDuration, formatNumber } from '@/utils/format'; +import { DISTANCE_UNITS, convertDistance, getDefaultDistanceUnit } from '@/utils/units'; + +import TimeInput from '@/components/TimeInput.vue'; + +/** + * The split targets + */ +const targets = defineModel({ + type: Array, + default: () => [], +}) + +const props = defineProps({ + /** + * The unit system to use when showing result paces + */ + defaultUnitSystem: { + type: String, + default: 'metric', + }, +}); + +/** + * The target table results + */ +const results = computed(() => { + // Initialize results array + const results = []; + + for (let i = 0; i < targets.value.length; i += 1) { + // Calculate split and total times + const splitTime = targets.value[i].splitTime || 0; + const totalTime = i === 0 ? splitTime : results[i - 1].time + splitTime; + + // Calculate split and total distances + const totalDistance = convertDistance( + targets.value[i].distanceValue, + targets.value[i].distanceUnit, 'meters', + ); + const splitDistance = i === 0 ? totalDistance : totalDistance - results[i - 1].distance; + + // Calculate pace + const pace = splitTime / convertDistance(splitDistance, 'meters', + getDefaultDistanceUnit(props.defaultUnitSystem)); + + // Add row to results array + results.push({ + distance: totalDistance, + distanceValue: targets.value[i].distanceValue, + distanceUnit: targets.value[i].distanceUnit, + time: totalTime, + splitTime, + pace, + }); + } + + // Return results array + return results; +}); +</script> + +<style scoped> +/* Show/hide mobile abbreviations */ +.split-output-table th:first-child span.mobile-abbreviation { + display: none; +} +@media only screen and (max-width: 500px) { + .split-output-table th:first-child span:not(.mobile-abbreviation) { + display: none; + } + .split-output-table th:first-child span.mobile-abbreviation { + display: inherit; + } +} +</style> diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -7,13 +7,13 @@ <input v-model="internalValue.name" placeholder="Target set label" aria-label="Target set label"/> <button class="icon" :title="isCustomSet ? 'Delete target set' : 'Revert target set'" - @click="revert"> + @click="emit('revert')"> <vue-feather :type="isCustomSet ? 'trash-2' : 'rotate-ccw'" aria-hidden="true"/> </button> </th> <th> - <button class="icon" title="Close" @click="close"> + <button class="icon" title="Close" @click="emit('close')"> <vue-feather type="x" aria-hidden="true"/> </button> </th> @@ -22,18 +22,34 @@ <tbody> <tr v-for="(item, index) in internalValue.targets" :key="index"> - <td v-if="item.result === 'time'"> - <decimal-input v-model="item.distanceValue" aria-label="Target distance value" - :min="0" :digits="2"/> - <select v-model="item.distanceUnit" aria-label="Target distance unit"> - <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> - {{ value.name }} - </option> - </select> - </td> - - <td v-else> - <time-input v-model="item.time" label="Target duration"/> + <td> + <span v-if="setType === 'workout'"> + <decimal-input v-model="item.splitValue" aria-label="Split distance value" + :min="0" :digits="2"/> + <select v-model="item.splitUnit" aria-label="Split distance unit"> + <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key"> + {{ value.name }} + </option> + </select> + </span> + + <span v-if="setType === 'workout'"> + &nbsp;@&nbsp; + </span> + + <span v-if="item.type === 'distance'"> + <decimal-input v-model="item.distanceValue" aria-label="Target distance value" + :min="0" :digits="2"/> + <select v-model="item.distanceUnit" aria-label="Target distance unit"> + <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key"> + {{ value.name }} + </option> + </select> + </span> + + <span v-else> + <time-input v-model="item.time" label="Target duration"/> + </span> </td> <td> @@ -45,7 +61,7 @@ <tr v-if="internalValue.targets.length === 0" class="empty-message"> <td colspan="2"> - There aren't any targets in this set yet + There aren't any targets in this set yet. </td> </tr> </tbody> @@ -56,146 +72,131 @@ <button title="Add distance target" @click="addDistanceTarget"> Add distance target </button> - <button title="Add time target" @click="addTimeTarget"> + <button title="Add time target" @click="addTimeTarget" v-if="setType !== 'split'"> Add time target </button> - <br/> - <p>Note: time targets are ignored by the Split Calculator</p> </td> </tr> </tfoot> </table> </template> -<script> +<script setup> +import { watch, ref } from 'vue'; + import VueFeather from 'vue-feather'; -import targetUtils from '@/utils/targets'; -import unitUtils from '@/utils/units'; +import { DISTANCE_UNITS, getDefaultDistanceUnit } from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; -export default { - name: 'TargetEditor', - - components: { - DecimalInput, - TimeInput, - VueFeather, +/** + * The component value + */ +const model = defineModel({ + type: Object, + default: { + name: 'New target set', + targets: [], + } +}); + +const props = defineProps({ + /** + * Whether the target set is a custom or default set + */ + isCustomSet: { + type: Boolean, + default: false, }, - props: { - /** - * The targets - */ - modelValue: { - type: Object, - default: JSON.parse(JSON.stringify(targetUtils.defaultTargetSet)), - }, - - /** - * Whether the target set is a custom or default set - */ - isCustomSet: { - type: Boolean, - default: false, - }, - - /** - * The unit system to use when creating distance targets - */ - defaultUnitSystem: { - type: String, - default: 'metric', - }, + /** + * The unit system to use when creating distance targets + */ + defaultUnitSystem: { + type: String, + default: 'metric', }, - data() { - return { - /** - * The internal value - */ - internalValue: this.modelValue, - - /** - * The distance units - */ - distanceUnits: unitUtils.DISTANCE_UNITS, - }; + /** + * The target set type ('standard', 'split', or 'workout') + */ + setType: { + type: String, + default: 'standard' }, +}); + +// Declare emitted events +const emit = defineEmits(['revert', 'close']); + +/** + * The internal value + */ +const internalValue = ref(model.value); + +/** + * Update the internal value when the component value changes + */ +watch(model, (newValue) => { + internalValue.value = newValue; +}, { deep: true }); + +/** + * Update the component value when the internal value changes + */ +watch(internalValue, (newValue) => { + model.value = newValue; +}, { deep: true }); + +/** + * Add a new distance based target + */ +function addDistanceTarget() { + if (props.setType === 'workout') { + internalValue.value.targets.push({ + type: 'distance', + distanceValue: 1, + distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem), + splitValue: 1, + splitUnit: getDefaultDistanceUnit(props.defaultUnitSystem), + }); + } else { + internalValue.value.targets.push({ + type: 'distance', + distanceValue: 1, + distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem), + }); + } +} - watch: { - /** - * Update the component value when the modelValue prop changes - * @param {Number} newValue The new prop value - */ - modelValue: { - deep: true, - handler(newValue) { - this.internalValue = newValue; - }, - }, - - /** - * Emit the input event when the component value changes - * @param {Number} newValue The new component value - */ - internalValue: { - deep: true, - handler(newValue) { - this.$emit('update:modelValue', newValue); - }, - }, - }, +/** + * Add a new time based target + */ +function addTimeTarget() { + if (props.setType === 'workout') { + internalValue.value.targets.push({ + type: 'time', + time: 600, + splitValue: 1, + splitUnit: getDefaultDistanceUnit(props.defaultUnitSystem), + }); + } else { + internalValue.value.targets.push({ + type: 'time', + time: 600, + }); + } +} - methods: { - /** - * Revert the target set - */ - revert() { - // Emit revert event - this.$emit('revert'); - }, - - /** - * Close the target editor - */ - close() { - // Emit close event - this.$emit('close'); - }, - - /** - * Add a new distance based target - */ - addDistanceTarget() { - this.internalValue.targets.push({ - result: 'time', - distanceValue: 1, - distanceUnit: unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem), - }); - }, - - /** - * Add a new time based target - */ - addTimeTarget() { - this.internalValue.targets.push({ - result: 'distance', - time: 600, - }); - }, - - /** - * Remove a target - * @param {Number} index The index of the target - */ - removeTarget(index) { - this.internalValue.targets.splice(index, 1); - }, - }, -}; +/** + * Remove a target + * @param {Number} index The index of the target + */ +function removeTarget(index) { + internalValue.value.targets.splice(index, 1); +} </script> <style scoped> @@ -203,6 +204,12 @@ export default { .target-editor th .icon { margin-left: 0.3em; } +.target-editor tbody td:first-child::not(.empty-message) { + display: flex; + gap: 0.2em; + flex-wrap: wrap; + align-items: center; +} .target-editor th:last-child, .target-editor td:last-child { text-align: right; } @@ -217,9 +224,6 @@ export default { .target-editor tfoot button { margin: 0.5em; } -.target-editor tfoot p { - margin-top: 0.5em; -} @media only screen and (max-width: 800px) { /* leave space for revert button on mobile devices */ .target-editor th input { diff --git a/src/components/TargetSetSelector.vue b/src/components/TargetSetSelector.vue @@ -7,146 +7,133 @@ <option value="_new">[ Create New Target Set ]</option> </select> - <button class="icon" title="Edit target set" - @click="reloadTargetSets(); sortTargetSet(); $refs.dialog.showModal()"> + <button class="icon" title="Edit target set" @click="editTargetSet()"> <vue-feather type="edit" aria-hidden="true"/> </button> - <dialog ref="dialog" class="target-set-editor-dialog" aria-label="Edit target set"> - <target-editor @close="$refs.dialog.close()" v-model="targetSets[internalValue]" - @revert="revertTargetSet" :default-unit-system="defaultUnitSystem" - :isCustomSet="!internalValue.startsWith('_')"/> + <dialog ref="dialogElement" class="target-set-editor-dialog" aria-label="Edit target set"> + <target-editor @close="sortTargetSet(); dialogElement.close()" + @revert="revertTargetSet" :default-unit-system="defaultUnitSystem" :setType="setType" + v-model="targetSets[internalValue]" :isCustomSet="!internalValue.startsWith('_')"/> </dialog> </span> </template> -<script> +<script setup> +import { computed, nextTick, ref } from 'vue'; + import VueFeather from 'vue-feather'; -import storage from '@/utils/localStorage'; -import targetUtils from '@/utils/targets'; +import { sort, defaultTargetSets } from '@/utils/targets'; import TargetEditor from '@/components/TargetEditor.vue'; -export default { - name: 'TargetSetSelector', - - components: { - TargetEditor, - VueFeather, +/** + * The selected target set + */ +const model = defineModel('selectedTargetSet', { + type: String, + default: '_new', +}); + +/** + * The target sets + */ +const targetSets = defineModel('targetSets', { + type: Object, + default: {}, +}); + +defineProps({ + /** + * The unit system to use when creating distance targets + */ + defaultUnitSystem: { + type: String, + default: 'metric', }, - props: { - /** - * The selected target set - */ - modelValue: { - type: String, - default: '_new', - }, - - /** - * The unit system to use when creating distance targets - */ - defaultUnitSystem: { - type: String, - default: 'metric', - }, + /** + * The target set type ('standard', 'split', or 'workout') + */ + setType: { + type: String, + default: 'standard' }, - - data() { - return { - /** - * The internal value - */ - internalValue: this.modelValue, - - /** - * The target sets - */ - targetSets: storage.get('target-sets', targetUtils.defaultTargetSets), - }; +}); + +/** + * The dialog element + */ +const dialogElement = ref(null); + +/** + * The internal value + */ +const internalValue = computed({ + get: () => { + if (model.value == '_new') { + newTargetSet(); + } + return model.value; }, - - watch: { - /** - * Update the component value when the modelValue prop changes - */ - modelValue(newValue) { - if (newValue !== this.internalValue) { - this.internalValue = newValue; - } - }, - - /** - * Emit the input event when the component value changes and create a new set if necessary - */ - internalValue: { - immediate: true, - handler(newValue) { - if (newValue == '_new') { - let key = Date.now().toString(); - this.targetSets[key] = { - name: 'New target set', - targets: [], - }; - this.internalValue = key; - } else { - this.$emit('update:modelValue', newValue); - } - }, - }, - - /** - * Save target sets - */ - targetSets: { - deep: true, - handler(newValue) { - storage.set('target-sets', newValue); - this.$emit('targets-updated'); - }, - }, + set: async (newValue) => { + if (newValue == '_new') { + await nextTick(); // <select> won't update if value changed immediately + newTargetSet(); + } else { + model.value = newValue; + } }, +}); + +/** + * Open TargetEditor for the current target set + */ +function editTargetSet() { + if (dialogElement.value && dialogElement.value.showModal) { + // Missing in test environments, but is difficult to mock because it may be referenced on mount + dialogElement.value.showModal(); + } +} - methods: { - /** - * Revert or remove the current target set - */ - revertTargetSet() { - if (this.internalValue.startsWith('_')) { - // Revert default set - this.targetSets[this.internalValue] = - JSON.parse(JSON.stringify(targetUtils.defaultTargetSets[this.internalValue])); - this.sortTargetSet(); - } else { - // Remove custom set - delete this.targetSets[this.internalValue]; - this.internalValue = [...Object.keys(this.targetSets), '_new'][0]; - if (this.$refs.dialog.close) this.$refs.dialog.close(); - } - }, - - /** - * Sort the current target set - */ - sortTargetSet() { - this.targetSets[this.internalValue].targets = - targetUtils.sort(this.targetSets[this.internalValue].targets); - }, - - /** - * Reload the target sets - */ - reloadTargetSets() { - this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); - }, - }, +/** + * Create and select a new target + */ +function newTargetSet() { + let key = Date.now().toString(); + targetSets.value[key] = { + name: 'New target set', + targets: [], + }; + model.value = key; + editTargetSet(); +} - activated() { - this.reloadTargetSets(); - }, -}; +/** + * Revert or remove the current target set + */ +function revertTargetSet() { + if (internalValue.value.startsWith('_')) { + // Revert default set + targetSets.value[internalValue.value] = + JSON.parse(JSON.stringify(defaultTargetSets[internalValue.value])); + sortTargetSet(); + } else { + // Remove custom set + delete targetSets.value[internalValue.value]; + internalValue.value = [...Object.keys(targetSets.value), '_new'][0]; + if (dialogElement.value.close) dialogElement.value.close(); + } +} + +/** + * Sort the current target set + */ +function sortTargetSet() { + targetSets.value[internalValue.value].targets = + sort(targetSets.value[internalValue.value].targets); +} </script> <style scoped> @@ -155,7 +142,7 @@ export default { } .target-set-editor-dialog { - width: min(100% - 2em, 400px); + width: min(100% - 2em, 450px); max-height: min(100% - 2em, 815px); margin-top: 100px; } diff --git a/src/components/TimeInput.vue b/src/components/TimeInput.vue @@ -13,145 +13,126 @@ </div> </template> -<script> +<script setup> +import { computed, ref, watch } from 'vue'; + import IntegerInput from '@/components/IntegerInput.vue'; import DecimalInput from '@/components/DecimalInput.vue'; -export default { - name: 'TimeInput', - - components: { - IntegerInput, - DecimalInput, +/** + * The component value + */ +const model = defineModel({ + type: Number, + default: 0, + validator(value) { + return value >= 0 && value <= 359999.99; }, +}); - props: { - /** - * The input value - */ - modelValue: { - type: Number, - default: 0, - validator(value) { - return value >= 0 && value <= 359999.99; - }, - }, - - /** - * Whether to show the hour field - */ - showHours: { - type: Boolean, - default: true, - }, - - /** - * The prefix for each field's aria-label - */ - label: { - type: String, - default: '', - }, +const props = defineProps({ + /** + * Whether to show the hour field + */ + showHours: { + type: Boolean, + default: true, }, - data() { - return { - /** - * The internal value - */ - internalValue: this.modelValue, - }; + /** + * The prefix for each field's aria-label + */ + label: { + type: String, + default: '', }, +}); - computed: { - /** - * The maximum value - */ - max() { - return this.showHours ? 359999.99 : 3599.99; - }, +/** + * The internal value + */ +const internalValue = ref(model.value); - /** - * The value of the hours field - */ - hours: { - get() { - return Math.floor(this.modelValue / 3600); - }, - set(newValue) { - this.internalValue = (newValue * 3600) + (this.minutes * 60) + this.seconds; - }, - }, +/** + * The maximum value + */ +const max = computed(() => { + return props.showHours ? 359999.99 : 3599.99; +}); - /** - * The value of the minutes field - */ - minutes: { - get() { - return Math.floor((this.modelValue % 3600) / 60); - }, - set(newValue) { - this.internalValue = (this.hours * 3600) + (newValue * 60) + this.seconds; - }, - }, - - /** - * The value of the seconds field - */ - seconds: { - get() { - return this.modelValue % 60; - }, - set(newValue) { - this.internalValue = (this.hours * 3600) + (this.minutes * 60) + newValue; - }, - }, +/** + * The value of the hours field + */ +const hours = computed({ + get() { + return Math.floor(model.value / 3600); }, + set(newValue) { + internalValue.value = (newValue * 3600) + (minutes.value * 60) + seconds.value; + }, +}); - watch: { - /** - * Update the component value when the modelValue prop changes - * @param {Number} newValue The new prop value - */ - modelValue(newValue) { - if (newValue !== this.internalValue) { - this.internalValue = newValue; - } - }, - - /** - * Emit the input event when the component value changes - * @param {Number} newValue The new component value - */ - internalValue(newValue) { - this.$emit('update:modelValue', newValue); - }, +/** + * The value of the minutes field + */ +const minutes = computed({ + get() { + return Math.floor((model.value % 3600) / 60); }, + set(newValue) { + internalValue.value = (hours.value * 3600) + (newValue * 60) + seconds.value; + }, +}); - methods: { - /** - * Process up and down arrow presses - * @param {Object} e The keydown event args - */ - onkeydown(e, step = 1) { - if (e.key === 'ArrowUp') { - if (Math.floor(this.internalValue) + step > this.max) { - this.internalValue = this.max; - } else { - this.internalValue = Math.floor(this.internalValue) + step; - } - e.preventDefault(); - } else if (e.key === 'ArrowDown') { - if (Math.ceil(this.internalValue) - step < 0) { - this.internalValue = 0; - } else { - this.internalValue = Math.ceil(this.internalValue) - step; - } - e.preventDefault(); - } - }, +/** + * The value of the seconds field + */ +const seconds = computed({ + get() { + return model.value % 60; + }, + set(newValue) { + internalValue.value = (hours.value * 3600) + (minutes.value * 60) + newValue; }, -}; +}); + +/** + * Update the internal value when the component value changes + */ +watch(model, (newValue) => { + if (newValue !== internalValue.value) { + internalValue.value = newValue; + } +}); + +/** + * Update the component value when the internal value changes + */ +watch(internalValue, (newValue) => { + model.value = newValue; +}); + +/** + * Process up and down arrow presses + * @param {Object} e The keydown event args + */ +function onkeydown(e, step = 1) { + if (e.key === 'ArrowUp') { + if (Math.floor(internalValue.value) + step > max.value) { + internalValue.value = max.value; + } else { + internalValue.value = Math.floor(internalValue.value) + step; + } + e.preventDefault(); + } else if (e.key === 'ArrowDown') { + if (Math.ceil(internalValue.value) - step < 0) { + internalValue.value = 0; + } else { + internalValue.value = Math.ceil(internalValue.value) - step; + } + e.preventDefault(); + } +} </script> <style scoped> diff --git a/src/composables/useStorage.js b/src/composables/useStorage.js @@ -0,0 +1,36 @@ +import { ref, onActivated, watchEffect } from 'vue'; + +// The global localStorage prefix +const prefix = 'running-tools'; + +/* + * Create a reactive value that is synced with a localStorage item + * @param {String} key The localStorage item's key + * @defaultValue {Object} defaultValue The default value + */ +export default function useStorage(key, defaultValue) { + const clonedDefault = JSON.parse(JSON.stringify(defaultValue)); + const value = ref(clonedDefault); + + // (Re)load value from localStorage + function updateValue() { + let parsedValue; + try { + parsedValue = JSON.parse(localStorage.getItem(`${prefix}.${key}`)); + } catch { + parsedValue = null; + } + if (parsedValue !== null) value.value = parsedValue; + } + updateValue(); + onActivated(updateValue); + + // Save value to localStorage when modified + watchEffect(() => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value.value)); + } + }) + + return value +} diff --git a/src/router/index.js b/src/router/index.js @@ -1,9 +1,11 @@ import { createRouter, createWebHashHistory } from 'vue-router'; import HomePage from '@/views/HomePage.vue'; import AboutPage from '@/views/AboutPage.vue'; +import BatchCalculator from '@/views/BatchCalculator.vue'; import PaceCalculator from '@/views/PaceCalculator.vue'; import RaceCalculator from '@/views/RaceCalculator.vue'; import SplitCalculator from '@/views/SplitCalculator.vue'; +import WorkoutCalculator from '@/views/WorkoutCalculator.vue'; import UnitCalculator from '@/views/UnitCalculator.vue'; import NotFoundPage from '@/views/NotFoundPage.vue'; @@ -37,6 +39,15 @@ const router = createRouter({ redirect: '/home', }, { + path: '/calculate/batch', + name: 'calculate-batch', + component: BatchCalculator, + meta: { + title: 'Batch Calculator', + back: 'home', + }, + }, + { path: '/calculate/paces', name: 'calculate-paces', component: PaceCalculator, @@ -73,6 +84,15 @@ const router = createRouter({ }, }, { + path: '/calculate/workouts', + name: 'calculate-workouts', + component: WorkoutCalculator, + meta: { + title: 'Workout Calculator', + back: 'home', + }, + }, + { path: '/:pathMatch(.*)*', component: NotFoundPage, }, diff --git a/src/utils/calculators.js b/src/utils/calculators.js @@ -0,0 +1,173 @@ +import { formatDuration, formatNumber } from '@/utils/format'; +import * as paceUtils from '@/utils/paces'; +import * as raceUtils from '@/utils/races'; +import { DISTANCE_UNITS, convertDistance, getDefaultDistanceUnit } from '@/utils/units'; + +/** + * Format a distance/time result as a key/value result + * @param {Object} result The distance/time result + * @param {String} defaultUnitSystem The default unit system (imperial or metric) + * @returns {Object} The key/value result + */ +export function formatDistTimeResult(result, defaultUnitSystem) { + // Calculate numerical pace + const pace = result.time / convertDistance(result.distanceValue, result.distanceUnit, + getDefaultDistanceUnit(defaultUnitSystem)); + + return { + // Convert distance to key string + key: formatNumber(result.distanceValue, 0, 2, result.result === 'distance') + ' ' + + DISTANCE_UNITS[result.distanceUnit].symbol, + + // Convert time to time string + value: formatDuration(result.time, 3, 2, result.result === 'time'), + + // Convert pace to pace string + pace: formatDuration(pace, 3, 0, true) + ' / ' + + DISTANCE_UNITS[getDefaultDistanceUnit(defaultUnitSystem)].symbol, + + // Convert dist/time result to key/value + result: result.result === 'time' ? 'value' : 'key', + + // Use time (in seconds) as sort key + sort: result.time, + }; +} + +/** + * Calculate paces from a target + * @param {Object} input The input pace + * @param {Object} target The pace target + * @param {String} defaultUnitSystem The default unit system (imperial or metric) + * @returns {Object} The result + */ +export function calculatePaceResults(input, target, defaultUnitSystem) { + const result = { + distanceValue: target.distanceValue, + distanceUnit: target.distanceUnit, + time: target.time, + result: target.type === 'distance' ? 'time' : 'distance', + }; + + const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters'); + + // Add missing value to result + if (target.type === 'distance') { + // Convert target distance into meters + const d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters'); + + // Calculate time to travel distance at input pace + result.time = paceUtils.calculateTime(d1, input.time, d2); + } else { + // Calculate distance traveled in time at input pace + const d2 = paceUtils.calculateDistance(input.time, d1, target.time); + + // Convert output distance into default distance unit + const units = getDefaultDistanceUnit(defaultUnitSystem); + result.distanceValue = convertDistance(d2, 'meters', units); + result.distanceUnit = units; + } + + // Return result + return formatDistTimeResult(result, defaultUnitSystem); +} + +/** + * Predict race results from a target + * @param {Object} input The input race + * @param {Object} target The race target + * @param {Object} options The race prediction options + * @param {String} defaultUnitSystem The default unit system (imperial or metric) + * @returns {Object} The result + */ +export function calculateRaceResults(input, target, options, defaultUnitSystem) { + const result = { + distanceValue: target.distanceValue, + distanceUnit: target.distanceUnit, + time: target.time, + result: target.type === 'distance' ? 'time' : 'distance', + }; + + const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters'); + + // Add missing value to result + if (target.type === 'distance') { + // Convert target distance into meters + const d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters'); + + // Get prediction + result.time = raceUtils.predictTime(d1, input.time, d2, options.model, options.riegelExponent); + } else { + // Get prediction + let distance = raceUtils.predictDistance(input.time, d1, target.time, options.model, + options.riegelExponent); + + // Convert output distance into default distance unit + distance = convertDistance(distance, 'meters', + getDefaultDistanceUnit(defaultUnitSystem)); + + // Update result + result.distanceValue = distance; + result.distanceUnit = getDefaultDistanceUnit(defaultUnitSystem); + } + + // Return result + return formatDistTimeResult(result, defaultUnitSystem); +} + +/** + * Calculate race statistics from an input race + * @param {Object} input The input race + * @returns {Object} The race statistics + */ +export function calculateRaceStats(input) { + const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters'); + + return { + purdyPoints: raceUtils.getPurdyPoints(d1, input.time), + vo2Max: raceUtils.getVO2Max(d1, input.time), + vo2: raceUtils.getVO2(d1, input.time), + vo2MaxPercentage: raceUtils.getVO2Percentage(input.time) * 100, + } +} + +/** + * Predict workout results from a target + * @param {Object} input The input race + * @param {Object} target The workout target + * @param {Object} options The race prediction options + * @returns {Object} The result + */ +export function calculateWorkoutResults(input, target, options) { + const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters'); + const t1 = input.time; + const d3 = convertDistance(target.splitValue, target.splitUnit, 'meters'); + let d2, t2, t3; + + // Calculate pace + let key = formatNumber(target.splitValue, 0, 2, false) + ' ' + + DISTANCE_UNITS[target.splitUnit].symbol + ' @ '; + if (target.type === 'distance') { + // Convert target distance into meters + d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters'); + t2 = raceUtils.predictTime(d1, input.time, d2, options.model, options.riegelExponent); + key += formatNumber(target.distanceValue, 0, 2, false) + ' ' + + DISTANCE_UNITS[target.distanceUnit].symbol; + } else { + t2 = target.time; + d2 = raceUtils.predictDistance(t1, d1, t2, options.model, + options.riegelExponent); + key += formatDuration(target.time, 3, 2, false); + } + + t3 = paceUtils.calculateTime(d2, t2, d3); + + // Calculate time + return { + key: key, + value: formatDuration(t3, 3, 2, true), + pace: '', // Pace not used in workout calculator + result: 'value', + sort: t3, + } +} diff --git a/src/utils/format.js b/src/utils/format.js @@ -6,7 +6,7 @@ * @param {Boolean} extraDigits Whether to show extra zeros after the decimal point * @returns {String} The formatted value */ -function formatNumber(value, minPadding = 0, maxDigits = 2, extraDigits = true) { +export function formatNumber(value, minPadding = 0, maxDigits = 2, extraDigits = true) { // Initialize result let result = ''; @@ -51,7 +51,7 @@ function formatNumber(value, minPadding = 0, maxDigits = 2, extraDigits = true) * @param {Boolean} extraDigits Whether to show extra zeros after the decimal point * @returns {String} The formatted value */ -function formatDuration(value, minPadding = 6, maxDigits = 2, extraDigits = true) { +export function formatDuration(value, minPadding = 6, maxDigits = 2, extraDigits = true) { // Check if value is NaN if (Number.isNaN(value)) { return 'NaN'; @@ -97,8 +97,3 @@ function formatDuration(value, minPadding = 6, maxDigits = 2, extraDigits = true // Return result return result; } - -export default { - formatNumber, - formatDuration, -}; diff --git a/src/utils/localStorage.js b/src/utils/localStorage.js @@ -1,40 +0,0 @@ -// The global localStorage prefix -const prefix = 'running-tools'; - -/** - * Get the value of a key from localStorage - * @param {String} key The key - * @param {Object} defaultValue The default value - * @returns {Object} The value - */ -function get(key, defaultValue) { - // Clone defaultValue - const clonedDefault = JSON.parse(JSON.stringify(defaultValue)); - - if (key === null) { - return clonedDefault; - } - let value; - try { - value = JSON.parse(localStorage.getItem(`${prefix}.${key}`)); - } catch { - return clonedDefault; - } - return value === null ? clonedDefault : value; -} - -/** - * Set the value of a key in localStorage - * @param {String} key The key - * @param {Object} value The value - * */ -function set(key, value) { - if (typeof localStorage !== 'undefined') { - localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value)); - } -} - -export default { - get, - set, -}; diff --git a/src/utils/paces.js b/src/utils/paces.js @@ -1,35 +1,21 @@ /** - * Calculate pace from distance and time - * @param {Number} distance The distance (in meters) - * @param {Number} time The time (in seconds) - * @returns {Number} The pace (in seconds per meter) + * Calculate time from a distance and input pace + * @param {Number} d1 The input pace distance (in any unit) + * @param {Number} t1 The input pace time (in seconds) + * @param {Number} d2 The output distance (in the same unit as d1) + * @returns {Number} The output time (in seconds) */ -function getPace(distance, time) { - return time / distance; +export function calculateTime(d1, t1, d2) { + return (t1 / d1) * d2 } /** - * Calculate time from pace and distance - * @param {Number} pace The pace (in seconds per meter) - * @param {Number} distance The distance (in meters) - * @returns {Number} The time (in seconds) + * Calculate distance from a time and input pace + * @param {Number} t1 The input pace time (in seconds) + * @param {Number} d1 The input pace distance (in any unit) + * @param {Number} t2 The output time (in seconds) + * @returns {Number} The output distance (in the same unit as d1) */ -function getTime(pace, distance) { - return pace * distance; +export function calculateDistance(t1, d1, t2) { + return (d1 / t1) * t2 } - -/** - * Calculate distance from pace and time - * @param {Number} pace The pace (in seconds per meter) - * @param {Number} time The time (in seconds) - * @return {Number} The distance (in meters) - */ -function getDistance(pace, time) { - return time / pace; -} - -export default { - getPace, - getTime, - getDistance, -}; diff --git a/src/utils/races.js b/src/utils/races.js @@ -84,7 +84,7 @@ const PurdyPointsModel = { */ getPurdyPoints(d, t) { // Get variables - const variables = this.getVariables(d); + const variables = PurdyPointsModel.getVariables(d); // Calculate Purdy Points const points = variables.a * ((variables.twsec / t) - variables.b); @@ -102,10 +102,10 @@ const PurdyPointsModel = { */ predictTime(d1, t1, d2) { // Calculate Purdy Points for distance 1 - const points = this.getPurdyPoints(d1, t1); + const points = PurdyPointsModel.getPurdyPoints(d1, t1); // Calculate time for distance 2 - const variables = this.getVariables(d2); + const variables = PurdyPointsModel.getVariables(d2); const seconds = (variables.a * variables.twsec) / (points + (variables.a * variables.b)); // Return predicted time @@ -161,9 +161,9 @@ const PurdyPointsModel = { // Initialize estimate let estimate = (d1 * t2) / t1; - // Refine estimate - const method = (x) => this.predictTime(d1, t1, x); - const derivative = (x) => this.derivative(d1, t1, x) / 500; // Derivative on its own is too slow + // Refine estimate (derivative on its own is too slow) + const method = (x) => PurdyPointsModel.predictTime(d1, t1, x); + const derivative = (x) => PurdyPointsModel.derivative(d1, t1, x) / 500; estimate = NewtonsMethod(estimate, t2, method, derivative, 0.01); // Return estimate @@ -208,7 +208,7 @@ const VO2MaxModel = { * @returns {Number} The runner's VO2 max */ getVO2Max(d, t) { - const result = this.getVO2(d, t) / this.getVO2Percentage(t); + const result = VO2MaxModel.getVO2(d, t) / VO2MaxModel.getVO2Percentage(t); return result; }, @@ -237,14 +237,14 @@ const VO2MaxModel = { */ predictTime(d1, t1, d2) { // Calculate input VO2 max - const inputVO2 = this.getVO2Max(d1, t1); + const inputVO2 = VO2MaxModel.getVO2Max(d1, t1); // Initialize estimate let estimate = (t1 * d2) / d1; // Refine estimate - const method = (x) => this.getVO2Max(d2, x); - const derivative = (x) => this.VO2MaxTimeDerivative(d2, x); + const method = (x) => VO2MaxModel.getVO2Max(d2, x); + const derivative = (x) => VO2MaxModel.VO2MaxTimeDerivative(d2, x); estimate = NewtonsMethod(estimate, inputVO2, method, derivative, 0.0001); // Return estimate @@ -272,14 +272,14 @@ const VO2MaxModel = { */ predictDistance(t1, d1, t2) { // Calculate input VO2 max - const inputVO2 = this.getVO2Max(d1, t1); + const inputVO2 = VO2MaxModel.getVO2Max(d1, t1); // Initialize estimate let estimate = (d1 * t2) / t1; // Refine estimate - const method = (x) => this.getVO2Max(x, t2); - const derivative = (x) => this.VO2MaxDistanceDerivative(x, t2); + const method = (x) => VO2MaxModel.getVO2Max(x, t2); + const derivative = (x) => VO2MaxModel.VO2MaxDistanceDerivative(x, t2); estimate = NewtonsMethod(estimate, inputVO2, method, derivative, 0.0001); // Return estimate @@ -332,8 +332,8 @@ const CameronModel = { let estimate = (d1 * t2) / t1; // Refine estimate - const method = (x) => this.predictTime(d1, t1, x); - const derivative = (x) => this.derivative(d1, t1, x); + const method = (x) => CameronModel.predictTime(d1, t1, x); + const derivative = (x) => CameronModel.derivative(d1, t1, x); estimate = NewtonsMethod(estimate, t2, method, derivative, 0.01); // Return estimate @@ -408,10 +408,54 @@ const AverageModel = { }, }; -export default { - PurdyPointsModel, - VO2MaxModel, - CameronModel, - RiegelModel, - AverageModel, -}; +/** + * Predict a race time + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d2 The distance of the output race in meters + * @param {String} model The race prediction model to use + * @param {Number} c The value of the exponent in Pete Riegel's Model + */ +export function predictTime(d1, t1, d2, model='AverageModel', c=1.06) { + switch (model) { + case 'AverageModel': + return AverageModel.predictTime(d1, t1, d2, c); + case 'PurdyPointsModel': + return PurdyPointsModel.predictTime(d1, t1, d2); + case 'VO2MaxModel': + return VO2MaxModel.predictTime(d1, t1, d2); + case 'RiegelModel': + return RiegelModel.predictTime(d1, t1, d2, c); + case 'CameronModel': + return CameronModel.predictTime(d1, t1, d2); + } +} + +/** + * Predict a race distance + * @param {Number} t1 The finish time of the input race in seconds + * @param {Number} d1 The distance of the input race in meters + * @param {Number} t2 The finish time of the output race in seconds + * @param {String} model The race prediction model to use + * @param {Number} c The value of the exponent in Pete Riegel's Model + */ +export function predictDistance(t1, d1, t2, model='AverageModel', c=1.06) { + switch (model) { + default: + case 'AverageModel': + return AverageModel.predictDistance(t1, d1, t2, c); + case 'PurdyPointsModel': + return PurdyPointsModel.predictDistance(t1, d1, t2); + case 'VO2MaxModel': + return VO2MaxModel.predictDistance(t1, d1, t2); + case 'RiegelModel': + return RiegelModel.predictDistance(t1, d1, t2, c); + case 'CameronModel': + return CameronModel.predictDistance(t1, d1, t2); + } +} + +export const getPurdyPoints = PurdyPointsModel.getPurdyPoints; +export const getVO2 = VO2MaxModel.getVO2; +export const getVO2Percentage = VO2MaxModel.getVO2Percentage; +export const getVO2Max = VO2MaxModel.getVO2Max; diff --git a/src/utils/targets.js b/src/utils/targets.js @@ -1,101 +1,111 @@ -import unitUtils from '@/utils/units'; +import { convertDistance } from '@/utils/units'; /** * Sort an array of targets * @param {Array} targets The array of targets * @returns {Array} The sorted targets */ -function sort(targets) { +export function sort(targets) { return [ - ...targets.filter((item) => item.result === 'time') - .sort((a, b) => unitUtils.convertDistance(a.distanceValue, a.distanceUnit, 'meters') - - unitUtils.convertDistance(b.distanceValue, b.distanceUnit, 'meters')), + ...targets.filter((item) => item.type === 'distance') + .sort((a, b) => convertDistance(a.distanceValue, a.distanceUnit, 'meters') + - convertDistance(b.distanceValue, b.distanceUnit, 'meters')), - ...targets.filter((item) => item.result === 'distance') + ...targets.filter((item) => item.type === 'time') .sort((a, b) => a.time - b.time), ]; } -const defaultTargetSets = { +export const defaultTargetSets = { '_pace_targets': { name: 'Common Pace Targets', - targets: [ - { result: 'time', distanceValue: 100, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 200, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 300, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 600, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1000, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1200, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, + targets: sort([ + { type: 'distance', distanceValue: 100, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 200, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 300, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 600, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1000, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1200, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1500, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 3200, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 3, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 4, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 4, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 6, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 8, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 6, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 8, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 10, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 6, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 8, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, - { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, + { type: 'distance', distanceValue: 0.5, distanceUnit: 'marathons' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'marathons' }, - { result: 'distance', time: 600 }, - { result: 'distance', time: 1800 }, - { result: 'distance', time: 3600 }, - ], + { type: 'time', time: 600 }, + { type: 'time', time: 1800 }, + { type: 'time', time: 3600 }, + ]), }, '_race_targets': { name: 'Common Race Targets', - targets: [ - { result: 'time', distanceValue: 400, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 800, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1500, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1600, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 3200, distanceUnit: 'meters' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + targets: sort([ + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1500, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 3200, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 8, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 15, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 6, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 8, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 15, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 0.5, distanceUnit: 'marathons' }, - { result: 'time', distanceValue: 1, distanceUnit: 'marathons' }, - ], + { type: 'distance', distanceValue: 0.5, distanceUnit: 'marathons' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'marathons' }, + ]), }, '_split_targets': { name: '5K Mile Splits', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + '_workout_targets': { + name: 'Common Workout Targets', + targets: [ + { + splitValue: 400, splitUnit: 'meters', + type: 'distance', distanceValue: 1, distanceUnit: 'miles', + }, + { + splitValue: 800, splitUnit: 'meters', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', + }, + { + splitValue: 1600, splitUnit: 'meters', + type: 'time', time: 3600, + }, + { + splitValue: 1, splitUnit: 'miles', + type: 'time', time: 7200, + }, ], }, -}; - -const defaultTargetSet = { - name: 'New target set', - targets: [], -}; - -export default { - sort, - defaultTargetSets, - defaultTargetSet, }; diff --git a/src/utils/units.js b/src/utils/units.js @@ -1,7 +1,7 @@ /** * The time units */ -const TIME_UNITS = { +export const TIME_UNITS = { seconds: { name: 'Seconds', symbol: 's', @@ -22,7 +22,7 @@ const TIME_UNITS = { /** * The distance units */ -const DISTANCE_UNITS = { +export const DISTANCE_UNITS = { meters: { name: 'Meters', symbol: 'm', @@ -53,7 +53,7 @@ const DISTANCE_UNITS = { /** * The speed units */ -const SPEED_UNITS = { +export const SPEED_UNITS = { meters_per_second: { name: 'Meters per Second', symbol: 'm/s', @@ -74,7 +74,7 @@ const SPEED_UNITS = { /** * The value of each pace unit in seconds per meter */ -const PACE_UNITS = { +export const PACE_UNITS = { seconds_per_meter: { name: 'Seconds per Meter', symbol: 's/m', @@ -99,7 +99,7 @@ const PACE_UNITS = { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertTime(inputValue, inputUnit, outputUnit) { +export function convertTime(inputValue, inputUnit, outputUnit) { return (inputValue * TIME_UNITS[inputUnit].value) / TIME_UNITS[outputUnit].value; } @@ -110,7 +110,7 @@ function convertTime(inputValue, inputUnit, outputUnit) { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertDistance(inputValue, inputUnit, outputUnit) { +export function convertDistance(inputValue, inputUnit, outputUnit) { return (inputValue * DISTANCE_UNITS[inputUnit].value) / DISTANCE_UNITS[outputUnit].value; } @@ -121,7 +121,7 @@ function convertDistance(inputValue, inputUnit, outputUnit) { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertSpeed(inputValue, inputUnit, outputUnit) { +export function convertSpeed(inputValue, inputUnit, outputUnit) { return (inputValue * SPEED_UNITS[inputUnit].value) / SPEED_UNITS[outputUnit].value; } @@ -132,7 +132,7 @@ function convertSpeed(inputValue, inputUnit, outputUnit) { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertPace(inputValue, inputUnit, outputUnit) { +export function convertPace(inputValue, inputUnit, outputUnit) { return (inputValue * PACE_UNITS[inputUnit].value) / PACE_UNITS[outputUnit].value; } @@ -143,7 +143,7 @@ function convertPace(inputValue, inputUnit, outputUnit) { * @param {String} outputUnit The unit of the output * @returns {Number} The output */ -function convertSpeedPace(inputValue, inputUnit, outputUnit) { +export function convertSpeedPace(inputValue, inputUnit, outputUnit) { // Calculate input speed let speed; if (inputUnit in PACE_UNITS) { @@ -163,7 +163,7 @@ function convertSpeedPace(inputValue, inputUnit, outputUnit) { * Detect the user's default unit system * @returns {String} The default unit system */ -function detectDefaultUnitSystem() { +export function detectDefaultUnitSystem() { const language = (navigator.language || navigator.userLanguage).toLowerCase(); if (language.endsWith('-us') || language.endsWith('-mm')) { return 'imperial'; @@ -176,7 +176,7 @@ function detectDefaultUnitSystem() { * @param {String} unitSystem The unit system * @returns {String} The default distance unit */ -function getDefaultDistanceUnit(unitSystem) { +export function getDefaultDistanceUnit(unitSystem) { return unitSystem === 'metric' ? 'kilometers' : 'miles'; } @@ -185,7 +185,7 @@ function getDefaultDistanceUnit(unitSystem) { * @param {String} unitSystem The unit system * @returns {String} The default speed unit */ -function getDefaultSpeedUnit(unitSystem) { +export function getDefaultSpeedUnit(unitSystem) { return unitSystem === 'metric' ? 'kilometers_per_hour' : 'miles_per_hour'; } @@ -194,24 +194,6 @@ function getDefaultSpeedUnit(unitSystem) { * @param {String} unitSystem The unit system * @returns {String} The default pace unit */ -function getDefaultPaceUnit(unitSystem) { +export function getDefaultPaceUnit(unitSystem) { return unitSystem === 'metric' ? 'seconds_per_kilometer' : 'seconds_per_mile'; } - -export default { - TIME_UNITS, - DISTANCE_UNITS, - SPEED_UNITS, - PACE_UNITS, - - convertTime, - convertDistance, - convertSpeed, - convertPace, - convertSpeedPace, - - detectDefaultUnitSystem, - getDefaultDistanceUnit, - getDefaultSpeedUnit, - getDefaultPaceUnit, -}; diff --git a/src/views/AboutPage.vue b/src/views/AboutPage.vue @@ -19,7 +19,23 @@ </p> <h2>The Calculators</h2> - <p>Running Tools contains four calculators:</p> + <p>Running Tools contains six calculators:</p> + + <h3>Batch Calculator</h3> + <p> + The <router-link :to="{ name: 'calculate-batch' }">Batch Calculator</router-link> calculates + results for a range of input times using the Pace, Race, or Workout Calculators. + Options such as the default unit system, selected target set, and race prediction model are + synced from the active calculator. + </p> + <p> + The Batch Calculator is useful for tasks such as: + </p> + <ul class="questions"> + <li>Generating a table of mile splits and the corresponding marathon finish times.</li> + <li>Generating a table of equivalent race results for many distances and speeds.</li> + <li>Generating a table of workout split times for an entire team.</li> + </ul> <h3>Pace Calculator</h3> <p> @@ -36,7 +52,6 @@ <li>What do I have to run per mile to finish a marathon in three hours? (6:52 per mile)</li> </ul> - <h3>Race Calculator</h3> <p> The <router-link :to="{ name: 'calculate-races' }">Race Calculator</router-link> takes a @@ -44,10 +59,12 @@ equivalent race results. The selected target set controls which distances and/or times the calculator predicts race results for. + Extra output statistics for the input race result are also available under the Race Statistics + section. </p> <p> - The Advanced section of the Race Calculator includes extra output statistics for the input - race result and the option to switch between the five supported race prediction models: + The Advanced Options section includes the option to switch between the five supported race + prediction models: </p> <ul> <li>The Purdy Points Model</li> @@ -85,12 +102,8 @@ <ul class="questions"> <li>How fast would I finish a 1600m if I ran the 400m laps in 90s, 85s, 80s, and 75s? (5:30)</li> <li>If I finished a 5K in 20:00 and ran the first 2 miles in 13:00, how fast was the last ~1.1 - miles? (6:19 per mile pace)</li> + miles? (6:19 / mi pace)</li> </ul> - <p> - <strong>Note:</strong> The split calculator only works with distance targets and ignores all - time targets. - </p> <h3>Unit Calculator</h3> <p> @@ -102,46 +115,57 @@ </p> <ul class="questions"> <li>How many miles is a 5K? (3.107 miles)</li> - <li>What is 10 mph in time per mile? (6:00 per mile)</li> + <li>What is 10 mph in time per mile? (6:00 / mi)</li> <li>What is 123.4 minutes in hh:mm:ss? (02:03:24)</li> </ul> + <h3>Workout Calculator</h3> + <p> + The <router-link :to="{ name: 'calculate-workouts' }">Workout Calculator</router-link> takes a + distance and duration as input and shows intermediate splits for other equivalent race + results. + The selected target set controls which race distances and/or times the calculator calculates + outputs for and the distances of the splits that are shown for these races. + The Advanced Options section includes the option to switch between the same five prediction + models that are available in the Race Calculator. + </p> + <p> + The Workout Calculator is useful for answering questions like: + </p> + <ul class="questions"> + <li>If I raced a 5K in 20:00, how fast should I run 400m intervals at mile pace? (about 1:27)</li> + <li>If I raced a mile in 5:00, what is my "threshold" (~1 hr race) pace? (about 5:50 / mi)</li> + </ul> + <p> + <strong>Note:</strong> Results are just estimated race splits that are helpful for estimating + target workout splits. + As with the Race Calculator, splits are most accurate for similar distances and assume equal + fitness. + </p> + <h2>Target Sets</h2> <p> - A target set is a collection of distances and times that the Pace, Race, and Split Calculators - will calculate results for. + A target set is a collection of distances and/or times that the Pace, Race, Split, or Workout + Calculators will calculate results for. These calculators will output a duration for each distance target and a distance for each time target. - Running Tools comes with three default target sets. - You can switch between these sets, modify the targets they contain, and add new targets sets - from within each supporting calculator. + Each of these calculators comes with a default target set and allows you to add new target + sets, modify existing target sets, and switch between sets that belong to the same + calculator. </p> <p> - <strong>Note:</strong> The split calculator only works with distance targets and ignores all - time targets. + <strong>Note:</strong> The split calculator only supports distance targets. The workout + calculator also includes a split distance field for each target. </p> </div> </template> -<script> +<script setup> import { version } from '/package.json'; import VueFeather from 'vue-feather'; -export default { - name: 'AboutPage', - - components: { - VueFeather - }, - - data() { - return { - version, - development: process.env.NODE_ENV === 'development', - }; - }, -}; +const development = process.env.NODE_ENV === 'development'; </script> <style scoped> @@ -161,7 +185,7 @@ p, blockquote, ul { } li { margin-bottom: 0.2em; - margin-left: 3em; + margin-left: 1.5em; } p { line-height: 1.3; @@ -181,9 +205,4 @@ p { filter: invert(1); } } -@media only screen and (max-width: 800px) { - li { - margin-left: 1.5em; - } -} </style> diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue @@ -0,0 +1,208 @@ +<template> + <div class="calculator"> + <h2>Batch Input</h2> + <div class="input"> + <pace-input v-model="input" aria-label="Input"/> + </div> + + <h2>Batch Options</h2> + <div class="input"> + <div> + Increment: + <time-input v-model="options.increment" label="Duration increment" :show-hours="false"/> + &times; + <integer-input v-model="options.rows" min="1" aria-label="Number of rows"/> + </div> + <div> + Calculator: + <select aria-label="Calculator" v-model="options.calculator"> + <option value="pace">Pace Calculator</option> + <option value="race">Race Calculator</option> + <option value="workout">Workout Calculator</option> + </select> + </div> + </div> + + <details> + <summary> + <h2>Advanced Options</h2> + </summary> + <div> + Default units: + <select v-model="defaultUnitSystem" aria-label="Default units"> + <option value="imperial">Miles</option> + <option value="metric">Kilometers</option> + </select> + </div> + <div> + Target Set: + <target-set-selector v-model:selectedTargetSet="selectedTargetSet" + v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> + </div> + <race-options v-if="options.calculator !== 'pace'" v-model="advancedOptions"/> + </details> + + <h2>Batch Results</h2> + <double-output-table class="output" :input-times="inputTimes" :input-distance="inputDistance" + :calculate-result="calculateResult" + :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> + </div> +</template> + +<script setup> +import { computed } from 'vue'; + +import * as calcUtils from '@/utils/calculators'; +import { defaultTargetSets } from '@/utils/targets'; +import { detectDefaultUnitSystem } from '@/utils/units'; + +import DoubleOutputTable from '@/components/DoubleOutputTable.vue'; +import IntegerInput from '@/components/IntegerInput.vue'; +import PaceInput from '@/components/PaceInput.vue'; +import RaceOptions from '@/components/RaceOptions.vue'; +import TargetSetSelector from '@/components/TargetSetSelector.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import useStorage from '@/composables/useStorage'; + +/** + * The input pace + */ +const input = useStorage('batch-calculator-input', { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, +}); + +/** + * The batch input options + */ +const options = useStorage('batch-calculator-options', { + calculator: 'workout', + increment: 15, + rows: 20, +}); + +/** + * The default unit system + */ +const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); + +/** + * The current selected target sets for each calculator + */ +const selectedPaceTargetSet = useStorage('pace-calculator-target-set', '_pace_targets'); +const selectedRaceTargetSet = useStorage('race-calculator-target-set', '_race_targets'); +const selectedWorkoutTargetSet = useStorage('workout-calculator-target-set', '_workout_targets'); + +/** + * The target sets for each calculator + */ +const paceTargetSets = useStorage('pace-calculator-target-sets', { + _pace_targets: defaultTargetSets._pace_targets +}); +const raceTargetSets = useStorage('race-calculator-target-sets', { + _race_targets: defaultTargetSets._race_targets +}); +const workoutTargetSets = useStorage('workout-calculator-target-sets', { + _workout_targets: defaultTargetSets._workout_targets +}); + +/** + * The advanced options for each calculator + */ +const raceOptions = useStorage('race-calculator-options', { + model: 'AverageModel', + riegelExponent: 1.06, +}); +const workoutOptions = useStorage('workout-calculator-options', { + model: 'AverageModel', + riegelExponent: 1.06, +}); + +/** + * The input distance + */ +const inputDistance = computed(() => ({ + distanceValue: input.value.distanceValue, + distanceUnit: input.value.distanceUnit, +})); + +/** + * The set of input times + */ +const inputTimes = computed(() => { + let results = []; + for (let i = 0; i < options.value.rows; i++) { + results.push(input.value.time + options.value.increment * i); + } + return results; +}); + +/** + * The selected target set for the current calculator + */ +const selectedTargetSet = computed({ + get: () => { + if (options.value.calculator === 'pace') { + return selectedPaceTargetSet.value; + } else if (options.value.calculator === 'race') { + return selectedRaceTargetSet.value; + } else { + return selectedWorkoutTargetSet.value; + } + }, + set: (newValue) => { + if (options.value.calculator === 'pace') { + selectedPaceTargetSet.value = newValue; + } else if (options.value.calculator === 'race') { + selectedRaceTargetSet.value = newValue; + } else { + selectedWorkoutTargetSet.value = newValue; + } + }, +}); + +/** + * The target sets for the current calculator + */ +const targetSets = computed(() => { + if (options.value.calculator === 'pace') { + return paceTargetSets.value; + } else if (options.value.calculator === 'race') { + return raceTargetSets.value; + } else { + return workoutTargetSets.value; + } +}); + +/** + * The advanced options for the current calculator + */ +const advancedOptions = computed(() => { + if (options.value.calculator === 'pace') { + return {}; + } else if (options.value.calculator === 'race') { + return raceOptions.value; + } else { + return workoutOptions.value; + } +}); + +/** + * The appropriate calculate_results function for the current calculator + */ +const calculateResult = computed(() => { + if (options.value.calculator === 'pace') { + return (x,y) => calcUtils.calculatePaceResults(x, y, defaultUnitSystem.value); + } else if (options.value.calculator === 'race') { + return (x,y) => calcUtils.calculateRaceResults(x, y, raceOptions.value, defaultUnitSystem.value); + } else { + return (x,y) => calcUtils.calculateWorkoutResults(x, y, workoutOptions.value); + } +}); +</script> + +<style scoped> +@import '@/assets/target-calculator.css'; +</style> diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue @@ -4,6 +4,11 @@ A collection of tools for runners and their coaches </p> <div class="calculators"> + <router-link :to="{ name: 'calculate-batch' }" v-slot="{ navigate }" custom> + <button @click="navigate"> + Batch Calculator + </button> + </router-link> <router-link :to="{ name: 'calculate-paces' }" v-slot="{ navigate }" custom> <button @click="navigate"> Pace Calculator @@ -24,6 +29,11 @@ Unit Calculator </button> </router-link> + <router-link :to="{ name: 'calculate-workouts' }" v-slot="{ navigate }" custom> + <button @click="navigate"> + Workout Calculator + </button> + </router-link> </div> <p class="about-link"> <router-link :to="{ name: 'about' }"> @@ -33,12 +43,6 @@ </div> </template> -<script> -export default { - name: 'HomePage', -}; -</script> - <style scoped> .home-page { text-align: center; @@ -47,28 +51,28 @@ export default { } .description { font-size: 1.5em; - margin-bottom: 1em; } .calculators { display: flex; - flex-direction: row; + flex-wrap: wrap; + gap: 0.5em; + justify-content: center; + + max-width: 39em; + margin: 1em auto; } .calculators button { - flex-grow: 1; + width: 12em; font-size: 1em; padding: 0.5em; - margin: 0em 0.3em; -} -.about-link { - margin-top: 1em; } -@media only screen and (max-width: 600px) { +@media only screen and (max-width: 500px) { .calculators { - flex-direction: column; + gap: 0.75em; } .calculators button { - margin: 0.3em 0em; padding: 0.75em 0.5em; + width: 100%; } } </style> diff --git a/src/views/NotFoundPage.vue b/src/views/NotFoundPage.vue @@ -1,21 +1,18 @@ <template> <div class="not-found-page"> <h1>404 Not Found</h1> - <p><router-link to="/home">homepage</router-link></p> + <p><router-link to="/home">Return home</router-link></p> </div> </template> -<script> -export default { - name: 'NotFoundPage', -}; -</script> - <style scoped> -h1 { - font-size: 1.5em; -} .not-found-page { text-align: center; } +.not-found-page h1 { + font-size: 1.5em; +} +.not-found-page p { + margin-top: 0.5em; +} </style> diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -2,20 +2,7 @@ <div class="calculator"> <h2>Input Pace</h2> <div class="input"> - <div> - Distance: - <decimal-input v-model="inputDistance" aria-label="Input distance value" - :min="0" :digits="2"/> - <select v-model="inputUnit" aria-label="Input distance unit"> - <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> - {{ value.name }} - </option> - </select> - </div> - <div> - Time: - <time-input v-model="inputTime" label="Input duration"/> - </div> + <pace-input v-model="input"/> </div> <details> @@ -31,186 +18,54 @@ </div> <div> Target Set: - <target-set-selector v-model="selectedTargetSet" @targets-updated="reloadTargets" - :default-unit-system="defaultUnitSystem"/> + <target-set-selector v-model:selectedTargetSet="selectedTargetSet" + v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> </div> </details> <h2>Equivalent Paces</h2> - <simple-target-table class="output" :calculate-result="calculatePace" + <single-output-table class="output" :calculate-result="x => + calculatePaceResults(input, x, defaultUnitSystem)" :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> </div> </template> -<script> -import paceUtils from '@/utils/paces'; -import storage from '@/utils/localStorage'; -import targetUtils from '@/utils/targets'; -import unitUtils from '@/utils/units'; +<script setup> +import { calculatePaceResults } from '@/utils/calculators'; +import { defaultTargetSets } from '@/utils/targets'; +import { detectDefaultUnitSystem } from '@/utils/units'; -import DecimalInput from '@/components/DecimalInput.vue'; -import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; +import PaceInput from '@/components/PaceInput.vue'; +import SingleOutputTable from '@/components/SingleOutputTable.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; -import TimeInput from '@/components/TimeInput.vue'; - -export default { - name: 'PaceCalculator', - - components: { - DecimalInput, - SimpleTargetTable, - TargetSetSelector, - TimeInput, - }, - - data() { - return { - /** - * The input distance value - */ - inputDistance: storage.get('pace-calculator-input-distance', 5), - - /** - * The input distance unit - */ - inputUnit: storage.get('pace-calculator-input-unit', 'kilometers'), - - /** - * The input time value - */ - inputTime: storage.get('pace-calculator-input-time', 20 * 60), - - /** - * The default unit system - * - * Loaded in activate() method - */ - defaultUnitSystem: null, - - /** - * The names of the distance units - */ - distanceUnits: unitUtils.DISTANCE_UNITS, - - /** - * The current selected target set - */ - selectedTargetSet: storage.get('pace-calculator-target-set', '_pace_targets'), - - /** - * The target sets - * - * Loaded in activate() method - */ - targetSets: {}, - }; - }, - - watch: { - /** - * Save input distance value - */ - inputDistance(newValue) { - storage.set('pace-calculator-input-distance', newValue); - }, - - /** - * Save input distance unit - */ - inputUnit(newValue) { - storage.set('pace-calculator-input-unit', newValue); - }, - - /** - * Save input time value - */ - inputTime(newValue) { - storage.set('pace-calculator-input-time', newValue); - }, - - /** - * Save default unit system - */ - defaultUnitSystem(newValue) { - storage.set('default-unit-system', newValue); - }, - - /** - * Save the current selected target set - */ - selectedTargetSet(newValue) { - storage.set('pace-calculator-target-set', newValue); - }, - }, - - computed: { - /** - * The input pace (in seconds per meter) - */ - pace() { - const distance = unitUtils.convertDistance(this.inputDistance, this.inputUnit, 'meters'); - return paceUtils.getPace(distance, this.inputTime); - }, - }, - - methods: { - /** - * Reload the target sets - */ - reloadTargets() { - this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); - }, - - /** - * Calculate paces from a target - * @param {Object} target The target - * @returns {Object} The result - */ - calculatePace(target) { - // Initialize result - const result = { - distanceValue: target.distanceValue, - distanceUnit: target.distanceUnit, - time: target.time, - result: target.result, - }; - - // Add missing value to result - if (target.result === 'time') { - // Convert target distance into meters - const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters'); - - // Calculate time to travel distance at input pace - const time = paceUtils.getTime(this.pace, d2); - - // Update result - result.time = time; - } else { - // Calculate distance traveled in time at input pace - let distance = paceUtils.getDistance(this.pace, target.time); - - // Convert output distance into default distance unit - distance = unitUtils.convertDistance(distance, 'meters', - unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem)); - - // Update result - result.distanceValue = distance; - result.distanceUnit = unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem); - } - - // Return result - return result; - }, - }, - /** - * (Re)load settings used in multiple calculators - */ - activated() { - this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); - this.defaultUnitSystem = storage.get('default-unit-system', unitUtils.detectDefaultUnitSystem()); - }, -}; +import useStorage from '@/composables/useStorage'; + +/** + * The input pace + */ +const input = useStorage('pace-calculator-input', { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, +}); + +/** + * The default unit system + */ +const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); + +/** + * The current selected target set + */ +const selectedTargetSet = useStorage('pace-calculator-target-set', '_pace_targets'); + +/** + * The target sets + */ +const targetSets = useStorage('pace-calculator-target-sets', { + _pace_targets: defaultTargetSets._pace_targets +}); </script> <style scoped> diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -2,19 +2,7 @@ <div class="calculator"> <h2>Input Race Result</h2> <div class="input"> - <div> - Distance: - <decimal-input v-model="inputDistance" aria-label="Input distance value" :min="0" :digits="2"/> - <select v-model="inputUnit" aria-label="Input distance unit"> - <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> - {{ value.name }} - </option> - </select> - </div> - <div> - Time: - <time-input v-model="inputTime" label="Input race duration"/> - </div> + <pace-input v-model="input" label="Input race"/> </div> <details> @@ -22,14 +10,15 @@ <h2>Race Statistics</h2> </summary> <div> - Purdy Points: <b>{{ formatNumber(purdyPoints, 0, 1, true) }}</b> + Purdy Points: <b>{{ formatNumber(raceStats.purdyPoints, 0, 1, true) }}</b> </div> <div> - V&#775;O&#8322;: <b>{{ formatNumber(vo2, 0, 1, true) }}</b> ml/kg/min - (<b>{{ formatNumber(vo2Percentage, 0, 1, true) }}%</b> of max) + V&#775;O&#8322;: <b>{{ formatNumber(raceStats.vo2, 0, 1, true) }}</b> ml/kg/min + (<b>{{ formatNumber(raceStats.vo2MaxPercentage, 0, 1, true) }}%</b> of max) </div> <div> - V&#775;O&#8322; Max: <b>{{ formatNumber(vo2Max, 0, 1, true) }}</b> ml/kg/min + V&#775;O&#8322; Max: <b>{{ formatNumber(raceStats.vo2Max, 0, 1, true) }}</b> + ml/kg/min </div> </details> @@ -46,304 +35,72 @@ </div> <div> Target Set: - <target-set-selector v-model="selectedTargetSet" @targets-updated="reloadTargets" - :default-unit-system="defaultUnitSystem"/> - </div> - <div> - Prediction Model: - <select v-model="model" aria-label="Prediction model"> - <option value="AverageModel">Average</option> - <option value="PurdyPointsModel">Purdy Points Model</option> - <option value="VO2MaxModel">V&#775;O&#8322; Max Model</option> - <option value="CameronModel">Cameron's Model</option> - <option value="RiegelModel">Riegel's Model</option> - </select> - </div> - <div> - Riegel Exponent: - <decimal-input v-model="riegelExponent" aria-label="Riegel exponent" :min="1" :max="1.3" - :digits="2" :step="0.01"/> - (default: 1.06) + <target-set-selector v-model:selectedTargetSet="selectedTargetSet" + v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> </div> + <race-options v-model="options"/> </details> <h2>Equivalent Race Results</h2> - <simple-target-table class="output" :calculate-result="predictResult" :default-unit-system="defaultUnitSystem" - :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []" show-pace/> + <single-output-table class="output" show-pace + :calculate-result="x => calculateRaceResults(input, x, options, defaultUnitSystem)" + :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> </div> </template> -<script> -import formatUtils from '@/utils/format'; -import raceUtils from '@/utils/races'; -import storage from '@/utils/localStorage'; -import targetUtils from '@/utils/targets'; -import unitUtils from '@/utils/units'; - -import DecimalInput from '@/components/DecimalInput.vue'; -import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; -import TargetSetSelector from '@/components/TargetSetSelector.vue'; -import TimeInput from '@/components/TimeInput.vue'; - -export default { - name: 'RaceCalculator', - - components: { - DecimalInput, - SimpleTargetTable, - TargetSetSelector, - TimeInput, - }, - - data() { - return { - /** - * The input distance value - */ - inputDistance: storage.get('race-calculator-input-distance', 5), - - /** - * The input distance unit - */ - inputUnit: storage.get('race-calculator-input-unit', 'kilometers'), - - /** - * The input time value - */ - inputTime: storage.get('race-calculator-input-time', 20 * 60), - - /** - * The default unit system - * - * Loaded in activate() method - */ - defaultUnitSystem: null, - - /** - * The race prediction model - */ - model: storage.get('race-calculator-model', 'AverageModel'), - - /** - * The value of the exponent in Riegel's Model - */ - riegelExponent: storage.get('race-calculator-riegel-exponent', 1.06), - - /** - * The names of the distance units - */ - distanceUnits: unitUtils.DISTANCE_UNITS, - - /** - * The formatNumber method - */ - formatNumber: formatUtils.formatNumber, +<script setup> +import { computed } from 'vue'; - /** - * The current selected target set - */ - selectedTargetSet: storage.get('race-calculator-target-set', '_race_targets'), +import { calculateRaceResults, calculateRaceStats } from '@/utils/calculators'; +import { formatNumber } from '@/utils/format'; +import { defaultTargetSets } from '@/utils/targets'; +import { detectDefaultUnitSystem } from '@/utils/units'; - /** - * The target sets - * - * Loaded in activate() method - */ - targetSets: {}, - }; - }, - - methods: { - /** - * Reload the target sets - */ - reloadTargets() { - this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); - }, - - /** - * Predict race results from a target - * @param {Object} target The target - * @returns {Object} The result - */ - predictResult(target) { - // Initialize result - const result = { - distanceValue: target.distanceValue, - distanceUnit: target.distanceUnit, - time: target.time, - result: target.result, - }; - - // Add missing value to result - if (target.result === 'time') { - // Convert target distance into meters - const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, 'meters'); - - // Get prediction - let time; - switch (this.model) { - default: - case 'AverageModel': - time = raceUtils.AverageModel.predictTime(this.d1, this.inputTime, d2, - this.riegelExponent); - break; - case 'PurdyPointsModel': - time = raceUtils.PurdyPointsModel.predictTime(this.d1, this.inputTime, d2); - break; - case 'VO2MaxModel': - time = raceUtils.VO2MaxModel.predictTime(this.d1, this.inputTime, d2); - break; - case 'RiegelModel': - time = raceUtils.RiegelModel.predictTime(this.d1, this.inputTime, d2, - this.riegelExponent); - break; - case 'CameronModel': - time = raceUtils.CameronModel.predictTime(this.d1, this.inputTime, d2); - break; - } - - // Update result - result.time = time; - } else { - // Get prediction - let distance; - switch (this.model) { - default: - case 'AverageModel': - distance = raceUtils.AverageModel.predictDistance(this.inputTime, this.d1, target.time, - this.riegelExponent); - break; - case 'PurdyPointsModel': - distance = raceUtils.PurdyPointsModel.predictDistance(this.inputTime, this.d1, - target.time); - break; - case 'VO2MaxModel': - distance = raceUtils.VO2MaxModel.predictDistance(this.inputTime, this.d1, target.time); - break; - case 'RiegelModel': - distance = raceUtils.RiegelModel.predictDistance(this.inputTime, this.d1, target.time, - this.riegelExponent); - break; - case 'CameronModel': - distance = raceUtils.CameronModel.predictDistance(this.inputTime, this.d1, target.time); - break; - } - - // Convert output distance into default distance unit - distance = unitUtils.convertDistance(distance, 'meters', - unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem)); - - // Update result - result.distanceValue = distance; - result.distanceUnit = unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem); - } - - // Return result - return result; - }, - }, - - computed: { - /** - * The input distance in meters - */ - d1() { - return unitUtils.convertDistance(this.inputDistance, this.inputUnit, 'meters'); - }, - - /** - * The Purdy Points for the input race - */ - purdyPoints() { - const result = raceUtils.PurdyPointsModel.getPurdyPoints(this.d1, this.inputTime); - return result; - }, - - /** - * The VO2 Max calculated from the input race - */ - vo2Max() { - const result = raceUtils.VO2MaxModel.getVO2Max(this.d1, this.inputTime); - return result; - }, - - /** - * The VO2 calculated from the input race - */ - vo2() { - const result = raceUtils.VO2MaxModel.getVO2(this.d1, this.inputTime); - return result; - }, - - /** - * The percentage of VO2 Max calculated from the input race - */ - vo2Percentage() { - const result = raceUtils.VO2MaxModel.getVO2Percentage(this.inputTime) * 100; - return result; - }, - }, - - watch: { - /** - * Save input distance value - */ - inputDistance(newValue) { - storage.set('race-calculator-input-distance', newValue); - }, - - /** - * Save input distance unit - */ - inputUnit(newValue) { - storage.set('race-calculator-input-unit', newValue); - }, - - /** - * Save input time value - */ - inputTime(newValue) { - storage.set('race-calculator-input-time', newValue); - }, - - /** - * Save default unit system - */ - defaultUnitSystem(newValue) { - storage.set('default-unit-system', newValue); - }, - - /** - * Save prediction model - */ - model(newValue) { - storage.set('race-calculator-model', newValue); - }, - - /** - * Save Riegel Model exponent - */ - riegelExponent(newValue) { - storage.set('race-calculator-riegel-exponent', newValue); - }, - - /** - * Save the current selected target set - */ - selectedTargetSet(newValue) { - storage.set('race-calculator-target-set', newValue); - }, - }, +import PaceInput from '@/components/PaceInput.vue'; +import RaceOptions from '@/components/RaceOptions.vue'; +import SingleOutputTable from '@/components/SingleOutputTable.vue'; +import TargetSetSelector from '@/components/TargetSetSelector.vue'; - /** - * (Re)load settings used in multiple calculators - */ - activated() { - this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); - this.defaultUnitSystem = storage.get('default-unit-system', unitUtils.detectDefaultUnitSystem()); - }, -}; +import useStorage from '@/composables/useStorage'; + +/** + * The input race + */ +const input = useStorage('race-calculator-input', { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, +}); + +/** + * The default unit system + */ +const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); + +/** +* The race prediction options +*/ +const options = useStorage('race-calculator-options', { + model: 'AverageModel', + riegelExponent: 1.06, +}); + +/** + * The current selected target set + */ +const selectedTargetSet = useStorage('race-calculator-target-set', '_race_targets'); + +/** + * The target sets + */ +let targetSets = useStorage('race-calculator-target-sets', { + _race_targets: defaultTargetSets._race_targets +}); + +/** + * The statistics for the current input race + */ +const raceStats = computed(() => calculateRaceStats(input.value)); </script> <style scoped> diff --git a/src/views/SplitCalculator.vue b/src/views/SplitCalculator.vue @@ -1,244 +1,81 @@ <template> <div class="calculator"> - <div class="default-units"> - Default units: - <select v-model="defaultUnitSystem" aria-label="Default units"> - <option value="imperial">Miles</option> - <option value="metric">Kilometers</option> - </select> - </div> - - <div class="target-set"> - Target Set: - <target-set-selector v-model="selectedTargetSet" @targets-updated="reloadTargets" - :default-unit-system="defaultUnitSystem"/> + <div class="input"> + <div class="default-units"> + Default units: + <select v-model="defaultUnitSystem" aria-label="Default units"> + <option value="imperial">Miles</option> + <option value="metric">Kilometers</option> + </select> + </div> + + <div class="target-set"> + Target Set: + <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="split" + v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> + </div> </div> <div class="output"> - <table class="results"> - <thead> - <tr> - <th> - <span>Distance</span> - <span class="mobile-abbreviation">Dist.</span> - </th> - - <th>Time</th> - - <th>Split</th> - - <th>Pace</th> - </tr> - </thead> - - <tbody> - <tr v-for="(item, index) in results" :key="index"> - <td> - {{ formatNumber(item.distanceValue, 0, 2, false) }} - {{ distanceUnits[item.distanceUnit].symbol }} - </td> - - <td> - {{ formatDuration(item.totalTime, 3, 2, true) }} - </td> - - <td v-if="targetSets[selectedTargetSet]"> - <time-input v-model="targetSets[selectedTargetSet].targets[index].split" - label="Split duration" :showHours="false"/> - </td> - - <td> - {{ formatDuration(item.pace, 3, 0, true) }} - / {{ distanceUnits[getDefaultDistanceUnit(defaultUnitSystem)].symbol }} - </td> - </tr> - - <tr v-if="!targetSets[selectedTargetSet] || targetSets[selectedTargetSet].targets.length === 0" class="empty-message"> - <td colspan="5"> - There aren't any targets in this set yet. - </td> - </tr> - </tbody> - </table> + <split-output-table :default-unit-system="defaultUnitSystem" v-model="targetSet"/> </div> </div> </template> -<script> -import formatUtils from '@/utils/format'; -import storage from '@/utils/localStorage'; -import targetUtils from '@/utils/targets'; -import unitUtils from '@/utils/units'; - -import TargetSetSelector from '@/components/TargetSetSelector.vue'; -import TimeInput from '@/components/TimeInput.vue'; - -export default { - name: 'SplitCalculator', - - components: { - TargetSetSelector, - TimeInput, - }, - - data() { - return { - /** - * The default unit system - * - * Loaded in activate() method - */ - defaultUnitSystem: null, - - /** - * The distance units - */ - distanceUnits: unitUtils.DISTANCE_UNITS, +<script setup> +import { computed } from 'vue'; - /** - * The formatDuration method - */ - formatDuration: formatUtils.formatDuration, - - /** - * The formatNumber method - */ - formatNumber: formatUtils.formatNumber, - - /** - * The getDefaultDistanceUnit method - */ - getDefaultDistanceUnit: unitUtils.getDefaultDistanceUnit, - - /** - * The current selected target set - */ - selectedTargetSet: storage.get('split-calculator-target-set', '_split_targets'), - - /** - * The default output targets - * - * Loaded in activate() method - */ - targetSets: {}, - }; - }, +import { defaultTargetSets } from '@/utils/targets'; +import { detectDefaultUnitSystem } from '@/utils/units'; - watch: { - /** - * Save default unit system - */ - defaultUnitSystem(newValue) { - storage.set('default-unit-system', newValue); - }, - - /** - * Save the current selected target set - */ - selectedTargetSet(newValue) { - storage.set('split-calculator-target-set', newValue); - }, - - /** - * Save target sets - */ - targetSets: { - deep: true, - handler(newValue) { - storage.set('target-sets', newValue); - }, - }, - }, - - computed: { - /** - * The target table results - */ - results() { - // Initialize results array - const results = []; - - // Check for missing target set - if (!this.targetSets[this.selectedTargetSet]) return []; - - let targets = targetUtils.sort(this.targetSets[this.selectedTargetSet].targets.filter(x => - x.result === 'time')); - - for (let i = 0; i < targets.length; i += 1) { - // Calculate split and total times - const splitTime = targets[i].split || 0; - const totalTime = i === 0 ? splitTime : results[i - 1].totalTime + splitTime; - - // Calculate split and total distances - const totalDistance = unitUtils.convertDistance( - targets[i].distanceValue, - targets[i].distanceUnit, 'meters', - ); - const splitDistance = i === 0 ? totalDistance : totalDistance - results[i - 1].distance; - - // Calculate pace - const pace = splitTime / unitUtils.convertDistance(splitDistance, 'meters', - unitUtils.getDefaultDistanceUnit(this.defaultUnitSystem)); - - // Add row to results array - results.push({ - distance: totalDistance, - distanceValue: targets[i].distanceValue, - distanceUnit: targets[i].distanceUnit, - totalTime, - splitTime, - pace, - }); - } - - // Return results array - return results; - }, - }, +import SplitOutputTable from '@/components/SplitOutputTable.vue'; +import TargetSetSelector from '@/components/TargetSetSelector.vue'; - methods: { - /** - * Reload the target sets - */ - reloadTargets() { - this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); - }, +import useStorage from '@/composables/useStorage'; + +/** + * The default unit system + */ +const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); + +/** + * The current selected target set + */ +const selectedTargetSet = useStorage('split-calculator-target-set', '_split_targets'); + +/** + * The default output targets + */ +const targetSets = useStorage('split-calculator-target-sets', { + _split_targets: defaultTargetSets._split_targets +}); + +/** + * The active target set + */ +const targetSet = computed({ + get: () => { + if (targetSets.value[selectedTargetSet.value]) { + return targetSets.value[selectedTargetSet.value].targets + } else { + return [] + } }, - - /** - * (Re)load settings used in multiple calculators - */ - activated() { - this.targetSets = storage.get('target-sets', targetUtils.defaultTargetSets); - this.defaultUnitSystem = storage.get('default-unit-system', unitUtils.detectDefaultUnitSystem()); + set: (newValue) => { + if (targetSets.value[selectedTargetSet.value]) { + targetSets.value[selectedTargetSet.value].targets = newValue; + } }, -}; +}); </script> <style scoped> @import '@/assets/target-calculator.css'; -.target-set, .default-units { - margin-bottom: 5px; -} - /* Widen default calculator output */ @media only screen and (min-width: 501px) { .output { min-width: 400px; } } - -/* Show/hide mobile abbreviations */ -.results th:first-child span.mobile-abbreviation { - display: none; -} -@media only screen and (max-width: 500px) { - .results th:first-child span:not(.mobile-abbreviation) { - display: none; - } - .results th:first-child span.mobile-abbreviation { - display: inherit; - } -} </style> diff --git a/src/views/UnitCalculator.vue b/src/views/UnitCalculator.vue @@ -6,12 +6,12 @@ <option value="speed_and_pace">Speed &amp; Pace</option> </select> - <time-input v-if="getUnitType(inputUnit) === 'time'" class="input-value" - label="Input time" v-model="inputValue"/> + <time-input v-if="getUnitType(input.inputUnit) === 'time'" class="input-value" + label="Input time" v-model="input.inputValue"/> <decimal-input v-else class="input-value" aria-label="Input value" - v-model="inputValue" :min="0" :digits="2"/> + v-model="input.inputValue" :min="0" :digits="2"/> - <select v-model="inputUnit" class="input-units" aria-label="Input units"> + <select v-model="input.inputUnit" class="input-units" aria-label="Input units"> <option v-for="(value, key) in units" :key="key" :value="key"> {{ value.name }} </option> @@ -19,14 +19,14 @@ <span class="equals"> = </span> - <span v-if="getUnitType(outputUnit) === 'time'" class="output-value" aria-label="Output value"> + <span v-if="getUnitType(input.outputUnit) === 'time'" class="output-value" aria-label="Output value"> {{ formatDuration(outputValue, 6, 3, true) }} </span> <span v-else class="output-value" aria-label="Output value"> {{ formatNumber(outputValue, 0, 3, true) }} </span> - <select v-model="outputUnit" class="output-units" aria-label="Output units"> + <select v-model="input.outputUnit" class="output-units" aria-label="Output units"> <option v-for="(value, key) in units" :key="key" :value="key"> {{ value.name }} </option> @@ -34,235 +34,130 @@ </div> </template> -<script> -import formatUtils from '@/utils/format'; -import storage from '@/utils/localStorage'; -import unitUtils from '@/utils/units'; +<script setup> +import { computed, ref } from 'vue'; + +import { formatDuration, formatNumber } from '@/utils/format'; +import { DISTANCE_UNITS, TIME_UNITS, SPEED_UNITS, PACE_UNITS, convertDistance, convertTime, +convertSpeedPace } from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; -export default { - name: 'UnitCalculator', +import useStorage from '@/composables/useStorage'; - components: { - DecimalInput, - TimeInput, +/** + * The calculator inputs + */ +const inputs = useStorage('unit-calculator-inputs', { + distance: { + inputValue: 1, + inputUnit: 'miles', + outputUnit: 'kilometers', }, - - data() { - return { - /** - * The input value - */ - inputValue: storage.get('unit-calculator-distance-input-value', 1.0), - - /** - * The unit of the input - */ - inputUnit: storage.get('unit-calculator-distance-input-unit', 'miles'), - - /** - * The unit of the output - */ - outputUnit: storage.get('unit-calculator-distance-output-unit', 'kilometers'), - - /** - * The unit category - */ - category: 'distance', - - /** - * The formatDuration method - */ - formatDuration: formatUtils.formatDuration, - - /** - * The formatNumber method - */ - formatNumber: formatUtils.formatNumber, - }; + time: { + inputValue: 1, + inputUnit: 'seconds', + outputUnit: 'hh:mm:ss', }, - - computed: { - /** - * The names of the units in the current category - */ - units() { - switch (this.category) { - case 'distance': { - return unitUtils.DISTANCE_UNITS; - } - case 'time': { - return { - ...unitUtils.TIME_UNITS, - 'hh:mm:ss': { - name: 'hh:mm:ss', - symbol: '', - value: null, - }, - }; - } - case 'speed_and_pace': { - return { ...unitUtils.PACE_UNITS, ...unitUtils.SPEED_UNITS }; - } - default: { - return {}; - } - } - }, - - /** - * The output value - */ - outputValue() { - switch (this.category) { - case 'distance': { - return unitUtils.convertDistance(this.inputValue, this.inputUnit, this.outputUnit); - } - case 'time': { - // Correct input and output units for 'hh:mm:ss' unit - const realInput = this.inputUnit === 'hh:mm:ss' ? 'seconds' : this.inputUnit; - const realOutput = this.outputUnit === 'hh:mm:ss' ? 'seconds' : this.outputUnit; - - // Calculate conversion - return unitUtils.convertTime(this.inputValue, realInput, realOutput); - } - case 'speed_and_pace': { - return unitUtils.convertSpeedPace(this.inputValue, this.inputUnit, this.outputUnit); - } - default: { - return null; - } - } - }, + speed_and_pace: { + inputValue: 600, + inputUnit: 'seconds_per_mile', + outputUnit: 'miles_per_hour', }, - - watch: { - /** - * Reset inputValue, inputUnit, and outputUnit - */ - category(newValue) { - switch (newValue) { - case 'distance': { - this.inputValue = storage.get('unit-calculator-distance-input-value', 1); - this.inputUnit = storage.get('unit-calculator-distance-input-unit', 'miles'); - this.outputUnit = storage.get('unit-calculator-distance-output-unit', 'kilometers'); - break; - } - case 'time': { - this.inputValue = storage.get('unit-calculator-time-input-value', 1); - this.inputUnit = storage.get('unit-calculator-time-input-unit', 'seconds'); - this.outputUnit = storage.get('unit-calculator-time-output-unit', 'hh:mm:ss'); - break; - } - case 'speed_and_pace': { - this.inputValue = storage.get('unit-calculator-speed-input-value', 600); - this.inputUnit = storage.get('unit-calculator-speed-input-unit', - 'seconds_per_mile'); - this.outputUnit = storage.get('unit-calculator-speed-output-unit', - 'miles_per_hour'); - break; - } - default: { - break; - } - } - }, - - /** - * Save input value - */ - inputValue(newValue) { - switch (this.category) { - case 'distance': { - storage.set('unit-calculator-distance-input-value', newValue); - break; - } - case 'time': { - storage.set('unit-calculator-time-input-value', newValue); - break; - } - case 'speed_and_pace': { - storage.set('unit-calculator-speed-input-value', newValue); - break; - } - default: { - break; - } - } - }, - - /** - * Save input unit - */ - inputUnit(newValue) { - switch (this.category) { - case 'distance': { - storage.set('unit-calculator-distance-input-unit', newValue); - break; - } - case 'time': { - storage.set('unit-calculator-time-input-unit', newValue); - break; - } - case 'speed_and_pace': { - storage.set('unit-calculator-speed-input-unit', newValue); - break; - } - default: { - break; - } - } - }, - - /** - * Save output unit - */ - outputUnit(newValue) { - switch (this.category) { - case 'distance': { - storage.set('unit-calculator-distance-output-unit', newValue); - break; - } - case 'time': { - storage.set('unit-calculator-time-output-unit', newValue); - break; - } - case 'speed_and_pace': { - storage.set('unit-calculator-speed-output-unit', newValue); - break; - } - default: { - break; - } - } - }, +}); + +/** + * The unit category + */ +const category = ref('distance'); + +/** + * The inputs for the current category + */ +const input = computed({ + get() { + return inputs.value[category.value]; }, - - methods: { - /** - * Get the type of a unit - * @param {String} unit The unit - * @returns {String} The type ('decimal' or 'time') - */ - getUnitType(unit) { - if (unit in unitUtils.DISTANCE_UNITS) { - return 'decimal'; - } - if (unit in unitUtils.TIME_UNITS) { - return 'decimal'; - } - if (unit === 'hh:mm:ss') { - return 'time'; - } - if (['seconds_per_kilometer', 'seconds_per_mile'].includes(unit)) { - return 'time'; - } - return 'decimal'; - }, + set(newValue) { + inputs.value[category.value] = newValue; }, -}; +}); + +/** + * The names of the units in the current category + */ +const units = computed(() => { + switch (category.value) { + case 'distance': { + return DISTANCE_UNITS; + } + case 'time': { + return { + ...TIME_UNITS, + 'hh:mm:ss': { + name: 'hh:mm:ss', + symbol: '', + value: null, + }, + }; + } + case 'speed_and_pace': { + return { ...PACE_UNITS, ...SPEED_UNITS }; + } + default: { + return {}; + } + } +}); + +/** + * The output value + */ +const outputValue = computed(() => { + switch (category.value) { + case 'distance': { + return convertDistance(input.value.inputValue, input.value.inputUnit, + input.value.outputUnit); + } + case 'time': { + // Correct input and output units for 'hh:mm:ss' unit + const realInput = input.value.inputUnit === 'hh:mm:ss' ? 'seconds' : input.value.inputUnit; + const realOutput = input.value.outputUnit === 'hh:mm:ss' ? 'seconds' : input.value.outputUnit; + + // Calculate conversion + return convertTime(input.value.inputValue, realInput, realOutput); + } + case 'speed_and_pace': { + return convertSpeedPace(input.value.inputValue, input.value.inputUnit, + input.value.outputUnit); + } + default: { + return null; + } + } +}); + +/** + * Get the type of a unit + * @param {String} unit The unit + * @returns {String} The type ('decimal' or 'time') + */ +function getUnitType(unit) { + if (unit in DISTANCE_UNITS) { + return 'decimal'; + } + if (unit in TIME_UNITS) { + return 'decimal'; + } + if (unit === 'hh:mm:ss') { + return 'time'; + } + if (['seconds_per_kilometer', 'seconds_per_mile'].includes(unit)) { + return 'time'; + } + return 'decimal'; +} </script> <style scoped> diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue @@ -0,0 +1,83 @@ +<template> + <div class="calculator"> + <h2>Input Race Result</h2> + <div class="input"> + <pace-input v-model="input" label="Input race"/> + </div> + + <details> + <summary> + <h2>Advanced Options</h2> + </summary> + <div> + Default units: + <select v-model="defaultUnitSystem" aria-label="Default units"> + <option value="imperial">Miles</option> + <option value="metric">Kilometers</option> + </select> + </div> + <div> + Target Set: + <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="workout" + v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> + </div> + <race-options v-model="options"/> + </details> + + <h2>Workout Splits</h2> + <single-output-table class="output" + :calculate-result="x => calculateWorkoutResults(input, x, options)" + :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> + </div> +</template> + +<script setup> +import { calculateWorkoutResults } from '@/utils/calculators'; +import { defaultTargetSets } from '@/utils/targets'; +import { detectDefaultUnitSystem } from '@/utils/units'; + +import PaceInput from '@/components/PaceInput.vue'; +import RaceOptions from '@/components/RaceOptions.vue'; +import SingleOutputTable from '@/components/SingleOutputTable.vue'; +import TargetSetSelector from '@/components/TargetSetSelector.vue'; + +import useStorage from '@/composables/useStorage'; + +/** + * The input race + */ +const input = useStorage('workout-calculator-input', { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, +}); + +/** + * The default unit system + */ +const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); + +/** + * The race prediction options + */ +const options = useStorage('workout-calculator-options', { + model: 'AverageModel', + riegelExponent: 1.06, +}); + +/** + * The current selected target set + */ +const selectedTargetSet = useStorage('workout-calculator-target-set', '_workout_targets'); + +/** + * The target sets + */ +let targetSets = useStorage('workout-calculator-target-sets', { + _workout_targets: defaultTargetSets._workout_targets +}); +</script> + +<style scoped> +@import '@/assets/target-calculator.css'; +</style> diff --git a/tests/e2e/batch-calculator.spec.js b/tests/e2e/batch-calculator.spec.js @@ -0,0 +1,173 @@ +import { test, expect } from '@playwright/test'; + +test('Batch calculator', async ({ page }) => { + // Structure: + // - Test workout batch results, including modified prediction model + // - Test pace batch results, including modified default units + // - Test race batch results, including modified Riegel exponent + // - Reload page + // - Assert race batch results are still the same + // - Assert pace batch results are still the same + // - Assert workout batch results are still the same + + await page.goto('/'); + + // Go to batch calculator + await page.getByRole('button', { name: 'Batch Calculator' }).click(); + await expect(page).toHaveTitle('Batch Calculator - Running Tools'); + + // Enter input pace (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 duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('10'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Enter batch options (15 x 10s increments) + await page.getByLabel('Duration increment minutes').fill('0'); + await page.getByLabel('Duration increment seconds').fill('10'); + await page.getByLabel('Number of rows').fill('15'); + + // Assert workout results are correct + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:41.21'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:16.91'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row')).toHaveCount(16); + + // Change prediction model + await page.getByText('Advanced Options').click(); + await page.getByLabel('Prediction model').selectOption('Riegel\'s Model'); + + // Assert workout results are correct + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:40.78'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:16.51'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row')).toHaveCount(16); + + // Change calculator + await expect(page.getByLabel('Calculator')).toHaveValue('workout'); + await page.getByLabel('Calculator').selectOption('Pace Calculator'); + + // Assert pace results are correct + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(6)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(28)).toHaveText('10:00'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2:36.58'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(28)).toHaveText('1.90 mi'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(6)).toHaveText('3:11.38'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(28)).toHaveText('1.56 mi'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row')).toHaveCount(16); + + // Assert prediction options are hidden + await expect(page.getByLabel('Prediction model')).toHaveCount(0); + + // Change default units + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Assert pace results are correct + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(6)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(28)).toHaveText('10:00'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2:36.58'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(28)).toHaveText('3.07 km'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(6)).toHaveText('3:11.38'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(28)).toHaveText('2.51 km'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row')).toHaveCount(16); + + // Change calculator + await page.getByLabel('Calculator').selectOption('Race Calculator'); + + // Assert race results are correct + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:14.60'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:43.61'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row')).toHaveCount(16); + + // Change Riegel exponent + await expect(page.getByLabel('Prediction model')).toHaveValue('AverageModel'); + await page.getByLabel('Riegel Exponent').fill('1.12'); + + // Assert race results are correct + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:11.72'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:40.09'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row')).toHaveCount(16); + + // Reload page + await page.reload(); + + // Assert race results are correct (inputs and options not reset) + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:11.72'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:40.09'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row')).toHaveCount(16); + + // Assert pace results are correct (inputs and options not reset) + await page.getByLabel('Calculator').selectOption('Pace Calculator'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(6)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(28)).toHaveText('10:00'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2:36.58'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(28)).toHaveText('3.07 km'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(6)).toHaveText('3:11.38'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(28)).toHaveText('2.51 km'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row')).toHaveCount(16); + + // Assert workout results are correct (inputs and options not reset) + await page.getByLabel('Calculator').selectOption('Workout Calculator'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:40.78'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:16.51'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row')).toHaveCount(16); +}); diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js @@ -0,0 +1,206 @@ +import { test, expect } from '@playwright/test'; + +test('Cross-calculator', async ({ page }) => { + // Go to batch calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Batch Calculator' }).click(); + + // Enter input pace (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 duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('10'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Enter batch options (15 x 10s increments) + await page.getByLabel('Duration increment minutes').fill('0'); + await page.getByLabel('Duration increment seconds').fill('10'); + await page.getByLabel('Number of rows').fill('15'); + + // Change calculator + await page.getByLabel('Calculator').selectOption('Race Calculator'); + + // Change prediction model + await page.getByText('Advanced Options').click(); + await page.getByLabel('Prediction model').selectOption('Riegel\'s Model'); + + // 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 ]'); + await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Edit new target set + await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.'); + 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(); + + // 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 race distance value').fill('2'); + await page.getByLabel('Input race 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'); + + // 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: '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'); + + // Go to workout calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Workout Calculator' }).click(); + + // Enter input race (1 mi in 5:01) + await page.getByLabel('Input race distance value').fill('1'); + await page.getByLabel('Input race distance unit').selectOption('Miles'); + await page.getByLabel('Input race duration hours').fill('0'); + await page.getByLabel('Input race duration minutes').fill('5'); + await page.getByLabel('Input race duration seconds').fill('1'); + + // Change prediction model + await page.getByText('Advanced Options').click(); + await page.getByLabel('Prediction model').selectOption('V̇O₂ Max Model'); + + // Change default units (should update on other calculators too) + await page.getByLabel('Default units').selectOption('Kilometers'); + + // Return to batch calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Batch Calculator' }).click(); + + // Assert pace results are correct (inputs and options not reset) + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:24.04'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:56.05'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row')).toHaveCount(16); + + // Assert pace results are correct (inputs and options not reset, new pace targets loaded) + await page.getByLabel('Calculator').selectOption('Pace Calculator'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(4); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:36.58'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(4); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:11.38'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(4); + await expect(page.getByRole('row')).toHaveCount(16); + + // Assert workout results are correct (new workout options loaded) + await page.getByLabel('Calculator').selectOption('Workout Calculator'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:41.93'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:16.98'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row')).toHaveCount(16); + + // 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('0.4 km' + '1:55.57'); + await expect(page.getByRole('row').nth(2)).toHaveText('800 m' + '3:51.15'); + await expect(page.getByRole('row').nth(3)).toHaveText('2.08 km' + '10:00'); + await expect(page.getByRole('row')).toHaveCount(4); + + // 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 not resset and new prediction model loaded) + 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'); + + // Return to workout calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Workout Calculator' }).click(); + + // Assert workout splits are correct (input race and prediction model not reset) + await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:14.81'); + await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:53.58'); + await expect(page.getByRole('row')).toHaveCount(5); +}); diff --git a/tests/e2e/pace-calculator.spec.js b/tests/e2e/pace-calculator.spec.js @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; + +test('Pace Calculator', async ({ page }) => { + // Structure: + // - Test standard pace results + // - Test different default units + // - Test modified default target set + // - Test custom target set + // - Reload page + // - Assert outputs are still the same + // - Test target set deletion and reversion + + // Go to pace calculator + await page.goto('/'); + 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); + + // Edit default target set + 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('2.08 km' + '10:01'); + await expect(page.getByRole('row').nth(14)).toHaveText('1.5 mi' + '11:37.50'); + await expect(page.getByRole('row').nth(18)).toHaveText('3.95 km' + '19:00'); + await expect(page.getByRole('row')).toHaveCount(33); + + // Create custom target set + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Edit new target set + await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.'); + 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); + + // Reload page + await page.reload(); + + // Assert paces are correct (custom targets and default units not reset) + 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); + + // Switch target set + await page.getByText('Advanced Options').click(); + await page.getByLabel('Selected target set').selectOption('Less-common Pace Targets'); + + // 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('2.08 km' + '10:01'); + await expect(page.getByRole('row').nth(14)).toHaveText('1.5 mi' + '11:37.50'); + await expect(page.getByRole('row').nth(18)).toHaveText('3.95 km' + '19:00'); + await expect(page.getByRole('row')).toHaveCount(33); + + // Delete custom target set + await page.getByLabel('Selected target set').selectOption('800m Splits'); + 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('2.08 km' + '10:01'); + await expect(page.getByRole('row').nth(14)).toHaveText('1.5 mi' + '11:37.50'); + await expect(page.getByRole('row').nth(18)).toHaveText('3.95 km' + '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('2.08 km' + '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'); +}); diff --git a/tests/e2e/race-calculator.spec.js b/tests/e2e/race-calculator.spec.js @@ -0,0 +1,146 @@ +import { test, expect } from '@playwright/test'; + +test('Race Calculator', async ({ page }) => { + // Structure: + // - Test standard race results + // - Test different default units + // - Test different prediction options + // - Test modified default target set + // - Test custom target set + // - Reload page + // - Assert outputs are still the same + // - Test target set deletion and reversion + + // 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 race distance value').fill('2'); + await page.getByLabel('Input race 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); + + // Edit default target set + 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:53.11' + '3:00 / km'); + await expect(page.getByRole('row').nth(6)).toHaveText('1.5 mi' + '7:36.47' + '3:09 / km'); + await expect(page.getByRole('row').nth(12)).toHaveText('5.47 km' + '19:00' + '3:29 / km'); + await expect(page.getByRole('row')).toHaveCount(19); + + // Create custom target set + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Edit new target set + await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.'); + 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' + '17:11.77' + '3:26 / km'); + await expect(page.getByRole('row').nth(2)).toHaveText('10 km' + '37:22.53' + '3:44 / km'); + await expect(page.getByRole('row')).toHaveCount(3); + + // 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('5 km' + '17:11.77' + '3:26 / km'); + await expect(page.getByRole('row').nth(2)).toHaveText('10 km' + '37:22.53' + '3:44 / km'); + await expect(page.getByRole('row')).toHaveCount(3); + + // Switch target set + await page.getByText('Advanced Options').click(); + await page.getByLabel('Selected target set').selectOption('Less-common Race Targets'); + + // Assert race predictions are correct + await expect(page.getByRole('row').nth(5)).toHaveText('1.01 mi' + '4:53.11' + '3:00 / km'); + await expect(page.getByRole('row').nth(6)).toHaveText('1.5 mi' + '7:36.47' + '3:09 / km'); + await expect(page.getByRole('row').nth(12)).toHaveText('5.47 km' + '19:00' + '3:29 / km'); + await expect(page.getByRole('row')).toHaveCount(19); + + // Delete custom target set + await page.getByLabel('Selected target set').selectOption('XC Race Targets'); + 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(); + + // Assert race predictions are correct (back to default target set) + await expect(page.getByRole('row').nth(5)).toHaveText('1.01 mi' + '4:53.11' + '3:00 / km'); + await expect(page.getByRole('row').nth(6)).toHaveText('1.5 mi' + '7:36.47' + '3:09 / km'); + await expect(page.getByRole('row').nth(12)).toHaveText('5.47 km' + '19:00' + '3:29 / km'); + 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: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); + + // Assert title was reset + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('Common Race Targets'); +}); diff --git a/tests/e2e/split-calculator.spec.js b/tests/e2e/split-calculator.spec.js @@ -0,0 +1,191 @@ +import { test, expect } from '@playwright/test'; + +test('Split Calculator', async ({ page }) => { + // Structure: + // - Test standard split results + // - Test different default units + // - Test modified default target set + // - Test custom target set + // - Reload page + // - Assert outputs are still the same + // - Test target set deletion and reversion + + // 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 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('4:21 / 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:02 / 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:39 / km'); + await expect(page.getByRole('row')).toHaveCount(4); + + // 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: '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('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('13:30.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('0:00 / km'); + 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('32:30 / km'); + 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('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('19:30.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:45 / km'); + 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('2:30 / km'); + 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(4)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Edit new target set + await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.'); + 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 times and paces are correct (input splits initialized to zero) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('0.4 km'); + 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 / km'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(0)).toHaveText('800 m'); + 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 / km'); + await expect(page.getByRole('row')).toHaveCount(3); + + // Enter input 800m splits (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'); + + // Assert times and paces are correct + 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); + + // Reload page + await page.reload(); + + // Assert times and paces are correct (custom targets, split times, and default units not reset) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('0.4 km'); + 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(0)).toHaveText('800 m'); + 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); + + // 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('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('19:30.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:45 / km'); + 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('2:30 / km'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Delete custom target set + await page.getByLabel('Selected target set').selectOption('800m Splits'); + 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 times and paces are correct (back to default target set) + 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('19:30.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:45 / km'); + 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('2:30 / km'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Revert target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('5K 1600m Splits'); + await page.getByRole('button', { name: 'Revert target set' }).click(); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert times and paces are correct (split times are reverted) + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('1 mi'); + 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 / km'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(0)).toHaveText('2 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 / km'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(0)).toHaveText('5 km'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('0:00.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('0:00 / km'); + await expect(page.getByRole('row')).toHaveCount(4); + + // Assert title was reset + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('5K Mile Splits'); +}); diff --git a/tests/e2e/unit-calculator.spec.js b/tests/e2e/unit-calculator.spec.js @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; + +test('Unit Calculator', async ({ page }) => { + // Structure: + // - Test distance unit conversion + // - Test speed and pace unit conversion + // - Test time unit conversion + // - Reload page + // - Assert distance inputs are still loaded + // - Assert time inputs are still loaded + // - Assert speed and pace inputs are still loaded + + // 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'); + + // 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/tests/e2e/workout-calculator.spec.js b/tests/e2e/workout-calculator.spec.js @@ -0,0 +1,143 @@ +import { test, expect } from '@playwright/test'; + +test('Workout Calculator', async ({ page }) => { + // Structure: + // - Test standard workout results + // - Test different prediction options + // - Test modified default target set + // - Test custom target set + // - Reload page + // - Assert outputs are still the same + // - Test target set deletion and reversion + + // Go to workout calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Workout Calculator' }).click(); + await expect(page).toHaveTitle('Workout Calculator - Running Tools'); + + // Enter input race (2 mi in 10:30) + await page.getByLabel('Input race distance value').fill('2'); + await page.getByLabel('Input race 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 workout splits are correct + await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:13.45'); + await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:45.44'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Change prediction model + await page.getByText('Advanced Options').click(); + await page.getByLabel('Prediction model').selectOption('Riegel\'s Model'); + + // Assert workout splits are correct + await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:15.10'); + await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:45.64'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Change Riegel exponent + await page.getByLabel('Riegel Exponent').fill('1.12'); + + // Assert workout splits are correct + await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:12.04'); + await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '6:17.47'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Edit default target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await page.getByLabel('Target set label').fill('Less-common Workout Targets'); + await page.getByLabel('Split distance value').nth(0).fill('401'); + await page.getByLabel('Target distance value').nth(0).fill('2'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Split distance value').last().fill('1'); + await page.getByLabel('Split distance unit').last().selectOption('Miles'); + await page.getByLabel('Target distance value').last().fill('10'); + await page.getByLabel('Target distance unit').last().selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add time target' }).click(); + await page.getByLabel('Split distance value').last().fill('600'); + await page.getByLabel('Split distance unit').last().selectOption('Meters'); + 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 workout splits are correct + await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49'); + await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:05.14'); + await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '6:00.90'); + await expect(page.getByRole('row')).toHaveCount(7); + + // Create custom target set + await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); + await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Edit new target set + await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.'); + await expect(page.getByLabel('Target set label')).toHaveValue('New target set'); + await page.getByLabel('Target set label').fill('Workout Target Set #2'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Split distance value').last().fill('800'); + await page.getByLabel('Split distance unit').last().selectOption('Meters'); + await page.getByLabel('Target distance value').last().fill('5'); + await page.getByLabel('Target distance unit').last().selectOption('Kilometers'); + await page.getByRole('button', { name: 'Add distance target' }).click(); + await page.getByLabel('Split distance value').last().fill('1600'); + await page.getByLabel('Split distance unit').last().selectOption('Meters'); + await page.getByLabel('Target distance value').last().fill('10'); + await page.getByLabel('Target distance unit').last().selectOption('Kilometers'); + await page.getByRole('button', { name: 'Close' }).click(); + + // Assert workout splits are correct + await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:45.08'); + await expect(page.getByRole('row').nth(2)).toHaveText('1600 m @ 10 km' + '5:58.80'); + await expect(page.getByRole('row')).toHaveCount(3); + + // Reload page + await page.reload(); + + // Assert workout splits are correct (custom targets and model settings not reset) + await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:45.08'); + await expect(page.getByRole('row').nth(2)).toHaveText('1600 m @ 10 km' + '5:58.80'); + await expect(page.getByRole('row')).toHaveCount(3); + + // Switch target set + await page.getByText('Advanced Options').click(); + await page.getByLabel('Selected target set').selectOption('Less-common Workout Targets'); + + // Assert workout splits are correct + await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49'); + await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:05.14'); + await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '6:00.90'); + await expect(page.getByRole('row')).toHaveCount(7); + + // Delete custom target set + await page.getByLabel('Selected target set').selectOption('Workout Target Set #2'); + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('Workout Target Set #2'); + await page.getByRole('button', { name: 'Delete target set' }).click(); + + // Switch to default target set + await page.getByLabel('Selected target set').selectOption('Less-common Workout Targets'); + + // Assert workout splits are correct (back to default target set) + await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49'); + await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:05.14'); + await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '6:00.90'); + await expect(page.getByRole('row')).toHaveCount(7); + + // Revert target set + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('Less-common Workout 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(1)).toHaveText('400 m @ 1 mi' + '1:12.04'); + await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '6:17.47'); + await expect(page.getByRole('row')).toHaveCount(5); + + // Assert title was reset + await page.getByRole('button', { name: 'Edit target set' }).click(); + await expect(page.getByLabel('Target set label')).toHaveValue('Common Workout Targets'); +}); diff --git a/tests/unit/components/DoubleOutputTable.spec.js b/tests/unit/components/DoubleOutputTable.spec.js @@ -0,0 +1,96 @@ +import { test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import DoubleOutputTable from '@/components/DoubleOutputTable.vue'; + +test('should correctly render table body rows and headers', () => { + // Initialize component + const results = [ + { key: 'key1', value: 'value1', pace: 'pace1', result: 'value', sort: 2 }, + { key: 'key2', value: 'value2', pace: 'pace2', result: 'value', sort: 1 }, + { key: 'key3', value: 'value3', pace: 'pace3', result: 'value', sort: 3 }, + + { key: 'key4', value: 'value4', pace: 'pace4', result: 'key', sort: 2 }, + { key: 'key5', value: 'value5', pace: 'pace5', result: 'key', sort: 1 }, + { key: 'key6', value: 'value6', pace: 'pace6', result: 'key', sort: 3 }, + + { key: 'key7', value: 'value7', pace: 'pace7', result: 'value', sort: 2 }, + { key: 'key8', value: 'value8', pace: 'pace8', result: 'value', sort: 1 }, + { key: 'key9', value: 'value9', pace: 'pace9', result: 'value', sort: 3 }, + ]; + const wrapper = shallowMount(DoubleOutputTable, { + propsData: { + calculateResult: (col, row) => { + expect(col.distanceUnit).to.equal('miles'); + expect(col.distanceValue).to.equal(2); + return results[row.id + 3*(col.time - 600)]; + }, + targets: [ + { id: 0 }, + { id: 1 }, + { id: 2 }, + ], + inputTimes: [ 600, 601, 602 ], + inputDistance: { + distanceUnit: 'miles', + distanceValue: 2, + }, + }, + }); + + // Assert headers are correctly generated from first row of results + const headers = wrapper.findAll('th'); + expect(headers[0].element.textContent).to.equal('2 mi'); + expect(headers[1].element.textContent).to.equal('key1'); + expect(headers[2].element.textContent).to.equal('key2'); + expect(headers[3].element.textContent).to.equal('key3'); + expect(headers.length).to.equal(4); + + // Assert results are correctly rendered + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].element.textContent).to.equal('10:00'); + expect(rows[0].findAll('td')[1].element.textContent).to.equal('value1'); + expect(rows[0].findAll('td')[2].element.textContent).to.equal('value2'); + expect(rows[0].findAll('td')[3].element.textContent).to.equal('value3'); + expect(rows[0].findAll('td').length).to.equal(4); + expect(rows[1].findAll('td')[0].element.textContent).to.equal('10:01'); + expect(rows[1].findAll('td')[1].element.textContent).to.equal('key4'); + expect(rows[1].findAll('td')[2].element.textContent).to.equal('key5'); + expect(rows[1].findAll('td')[3].element.textContent).to.equal('key6'); + expect(rows[1].findAll('td').length).to.equal(4); + expect(rows[2].findAll('td')[0].element.textContent).to.equal('10:02'); + expect(rows[2].findAll('td')[1].element.textContent).to.equal('value7'); + expect(rows[2].findAll('td')[2].element.textContent).to.equal('value8'); + expect(rows[2].findAll('td')[3].element.textContent).to.equal('value9'); + expect(rows[2].findAll('td').length).to.equal(4); + expect(rows.length).to.equal(3); +}); + +test('Should display message when inputs are empty', () => { + // Initialize component + const wrapper = shallowMount(DoubleOutputTable, { + propsData: { + calculateResult: () => ({ key: 'a', value: 'b', result: 'value', sort: 0 }), + targets: [ + { id: 0 }, + { id: 1 }, + { id: 2 }, + ], + inputTimes: [], + inputDistance: { + distanceUnit: 'miles', + distanceValue: 2, + }, + }, + }); + + // Assert headers are correctly generated + const headers = wrapper.findAll('th'); + expect(headers[0].element.textContent).to.equal('2 mi'); + expect(headers.length).to.equal(1); + + // Assert results are correctly rendered + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].text()).to.equal('No inputs were specified.'); + expect(rows[0].findAll('td').length).to.equal(1); + expect(rows.length).to.equal(1); +}); diff --git a/tests/unit/components/PaceInput.spec.js b/tests/unit/components/PaceInput.spec.js @@ -0,0 +1,50 @@ +import { test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import PaceInput from '@/components/PaceInput.vue'; + +test('should be initialized to modelValue', () => { + // Initialize component + const wrapper = shallowMount(PaceInput, { + propsData: { + modelValue: { + distanceValue: 3, + distanceUnit: 'miles', + time: 1000, + } + }, + }); + + // Assert input fields are correct + expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3); + expect(wrapper.find('select').element.value).to.equal('miles'); + expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(1000); +}); + +test('should update modelValue when inputs are modified', async () => { + // Initialize component + const wrapper = shallowMount(PaceInput); + + // Update distance value + await wrapper.findComponent({ name: 'decimal-input' }).setValue(3); + expect(wrapper.vm.modelValue).to.deep.equal({ + distanceValue: 3, + distanceUnit: 'kilometers', + time: 1200, + }); + + // Update distance unit + await wrapper.find('select').setValue('miles'); + expect(wrapper.vm.modelValue).to.deep.equal({ + distanceValue: 3, + distanceUnit: 'miles', + time: 1200, + }); + + // Update time + await wrapper.findComponent({ name: 'time-input' }).setValue(1000); + expect(wrapper.vm.modelValue).to.deep.equal({ + distanceValue: 3, + distanceUnit: 'miles', + time: 1000, + }); +}); diff --git a/tests/unit/components/RaceOptions.spec.js b/tests/unit/components/RaceOptions.spec.js @@ -0,0 +1,38 @@ +import { test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import RaceOptions from '@/components/RaceOptions.vue'; + +test('should be initialized to modelValue', () => { + // Initialize component + const wrapper = shallowMount(RaceOptions, { + propsData: { + modelValue: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + } + }, + }); + + // Assert input fields are correct + expect(wrapper.find('select').element.value).to.equal('PurdyPointsModel'); + expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.2); +}); + +test('should update modelValue when inputs are modified', async () => { + // Initialize component + const wrapper = shallowMount(RaceOptions); + + // Update model + await wrapper.find('select').setValue('CameronModel'); + expect(wrapper.vm.modelValue).to.deep.equal({ + model: 'CameronModel', + riegelExponent: 1.06, + }); + + // Update Riegel exponent + await wrapper.findComponent({ name: 'decimal-input' }).setValue(1.3); + expect(wrapper.vm.modelValue).to.deep.equal({ + model: 'CameronModel', + riegelExponent: 1.3, + }); +}); diff --git a/tests/unit/components/SimpleTargetTable.spec.js b/tests/unit/components/SimpleTargetTable.spec.js @@ -1,123 +0,0 @@ -import { test, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; - -test('results should be correct and sorted by time', () => { - // Initialize component - const wrapper = shallowMount(SimpleTargetTable, { - propsData: { - calculateResult: (row) => ({ - distanceValue: row.distanceValue ? row.distanceValue : row.time / 300, - distanceUnit: row.distanceUnit ? row.distanceUnit : 'miles', - time: row.time ? row.time : row.distanceValue * 300, - result: row.result, - }), - targets: [ - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'distance', time: 1230 }, - ], - }, - }); - - // Assert results are correctly rendered - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('5:00.00'); - expect(rows[0].findAll('td').length).to.equal(2); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('3 mi'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('15:00.00'); - expect(rows[1].findAll('td').length).to.equal(2); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('4.10 mi'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('20:30'); - expect(rows[2].findAll('td').length).to.equal(2); - expect(rows[3].findAll('td')[0].element.textContent).to.equal('5 km'); - expect(rows[3].findAll('td')[1].element.textContent).to.equal('25:00.00'); - expect(rows[3].findAll('td').length).to.equal(2); - expect(rows.length).to.equal(4); -}); - -test('should show correct imperial paces when showPace is true', () => { - // Initialize component - const wrapper = shallowMount(SimpleTargetTable, { - propsData: { - calculateResult: (row) => ({ - distanceValue: row.distanceValue ? row.distanceValue : row.time / 300, - distanceUnit: row.distanceUnit ? row.distanceUnit : 'miles', - time: row.time ? row.time : row.distanceValue * 300, - result: row.result, - }), - targets: [ - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'distance', time: 1230 }, - ], - defaultUnitSystem: 'imperial', - showPace: true, - }, - }); - - // Assert results are correctly rendered - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('5:00.00'); - expect(rows[0].findAll('td')[2].element.textContent).to.equal('5:00 / mi'); - expect(rows[0].findAll('td').length).to.equal(3); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('3 mi'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('15:00.00'); - expect(rows[1].findAll('td')[2].element.textContent).to.equal('5:00 / mi'); - expect(rows[1].findAll('td').length).to.equal(3); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('4.10 mi'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('20:30'); - expect(rows[2].findAll('td')[2].element.textContent).to.equal('5:00 / mi'); - expect(rows[2].findAll('td').length).to.equal(3); - expect(rows[3].findAll('td')[0].element.textContent).to.equal('5 km'); - expect(rows[3].findAll('td')[1].element.textContent).to.equal('25:00.00'); - expect(rows[3].findAll('td')[2].element.textContent).to.equal('8:03 / mi'); - expect(rows[3].findAll('td').length).to.equal(3); - expect(rows.length).to.equal(4); -}); - -test('should show correct metric paces when showPace is true', () => { - // Initialize component - const wrapper = shallowMount(SimpleTargetTable, { - propsData: { - calculateResult: (row) => ({ - distanceValue: row.distanceValue ? row.distanceValue : row.time / 300, - distanceUnit: row.distanceUnit ? row.distanceUnit : 'miles', - time: row.time ? row.time : row.distanceValue * 300, - result: row.result, - }), - targets: [ - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'distance', time: 1230 }, - ], - defaultUnitSystem: 'metric', - showPace: true, - }, - }); - - // Assert results are correctly rendered - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('5:00.00'); - expect(rows[0].findAll('td')[2].element.textContent).to.equal('3:06 / km'); - expect(rows[0].findAll('td').length).to.equal(3); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('3 mi'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('15:00.00'); - expect(rows[1].findAll('td')[2].element.textContent).to.equal('3:06 / km'); - expect(rows[1].findAll('td').length).to.equal(3); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('4.10 mi'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('20:30'); - expect(rows[2].findAll('td')[2].element.textContent).to.equal('3:06 / km'); - expect(rows[2].findAll('td').length).to.equal(3); - expect(rows[3].findAll('td')[0].element.textContent).to.equal('5 km'); - expect(rows[3].findAll('td')[1].element.textContent).to.equal('25:00.00'); - expect(rows[3].findAll('td')[2].element.textContent).to.equal('5:00 / km'); - expect(rows[3].findAll('td').length).to.equal(3); - expect(rows.length).to.equal(4); -}); diff --git a/tests/unit/components/SingleOutputTable.spec.js b/tests/unit/components/SingleOutputTable.spec.js @@ -0,0 +1,103 @@ +import { test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import SingleOutputTable from '@/components/SingleOutputTable.vue'; + +test('results should be correct and sorted by sort key', () => { + // Initialize component + const results = [ + { key: 'key1', value: 'value1', pace: 'pace1', result: 'key', sort: 2 }, + { key: 'key2', value: 'value2', pace: 'pace2', result: 'key', sort: 1 }, + { key: 'key3', value: 'value3', pace: 'pace3', result: 'key', sort: 3 }, + ]; + const wrapper = shallowMount(SingleOutputTable, { + propsData: { + calculateResult: (row) => results[row.id], + targets: [ + { id: 0 }, + { id: 1 }, + { id: 2 }, + ], + }, + }); + + // Assert results are correctly rendered + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].element.textContent).to.equal('key2'); + expect(rows[0].findAll('td')[1].element.textContent).to.equal('value2'); + expect(rows[0].findAll('td').length).to.equal(2); + expect(rows[1].findAll('td')[0].element.textContent).to.equal('key1'); + expect(rows[1].findAll('td')[1].element.textContent).to.equal('value1'); + expect(rows[1].findAll('td').length).to.equal(2); + expect(rows[2].findAll('td')[0].element.textContent).to.equal('key3'); + expect(rows[2].findAll('td')[1].element.textContent).to.equal('value3'); + expect(rows[2].findAll('td').length).to.equal(2); + expect(rows.length).to.equal(3); +}); + +test('results should have correct classes', () => { + // Initialize component + const results = [ + { key: 'key1', value: 'value1', pace: 'pace1', result: 'value', sort: 1 }, + { key: 'key2', value: 'value2', pace: 'pace2', result: 'key', sort: 2 }, + { key: 'key3', value: 'value3', pace: 'pace3', result: 'value', sort: 3 }, + ]; + const wrapper = shallowMount(SingleOutputTable, { + propsData: { + calculateResult: (row) => results[row.id], + targets: [ + { id: 0 }, + { id: 1 }, + { id: 2 }, + ], + }, + }); + + // Assert results are correctly rendered + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].element.classList).toHaveLength(0); + expect(rows[0].findAll('td')[1].element.classList).to.contain(['result']); + expect(rows[0].findAll('td').length).to.equal(2); + expect(rows[1].findAll('td')[0].element.classList).to.contain(['result']); + expect(rows[1].findAll('td')[1].element.classList).toHaveLength(0); + expect(rows[1].findAll('td').length).to.equal(2); + expect(rows[2].findAll('td')[0].element.classList).toHaveLength(0); + expect(rows[2].findAll('td')[1].element.classList).contain(['result']); + expect(rows[2].findAll('td').length).to.equal(2); + expect(rows.length).to.equal(3); +}); + +test('should show correct paces when showPace is true', () => { + // Initialize component + const results = [ + { key: 'key1', value: 'value1', pace: 'pace1', result: 'key', sort: 1 }, + { key: 'key2', value: 'value2', pace: 'pace2', result: 'key', sort: 2 }, + { key: 'key3', value: 'value3', pace: 'pace3', result: 'key', sort: 3 }, + ]; + const wrapper = shallowMount(SingleOutputTable, { + propsData: { + calculateResult: (row) => results[row.id], + targets: [ + { id: 0 }, + { id: 1 }, + { id: 2 }, + ], + showPace: true, + }, + }); + + // Assert results are correctly rendered + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].element.textContent).to.equal('key1'); + expect(rows[0].findAll('td')[1].element.textContent).to.equal('value1'); + expect(rows[0].findAll('td')[2].element.textContent).to.equal('pace1'); + expect(rows[0].findAll('td').length).to.equal(3); + expect(rows[1].findAll('td')[0].element.textContent).to.equal('key2'); + expect(rows[1].findAll('td')[1].element.textContent).to.equal('value2'); + expect(rows[1].findAll('td')[2].element.textContent).to.equal('pace2'); + expect(rows[1].findAll('td').length).to.equal(3); + expect(rows[2].findAll('td')[0].element.textContent).to.equal('key3'); + expect(rows[2].findAll('td')[1].element.textContent).to.equal('value3'); + expect(rows[2].findAll('td')[2].element.textContent).to.equal('pace3'); + expect(rows[2].findAll('td').length).to.equal(3); + expect(rows.length).to.equal(3); +}); diff --git a/tests/unit/components/SplitOutputTable.spec.js b/tests/unit/components/SplitOutputTable.spec.js @@ -0,0 +1,157 @@ +import { test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import SplitOutputTable from '@/components/SplitOutputTable.vue'; + +test('should initialize undefined splits to 0:00.00', async () => { + // Initialize component + const wrapper = shallowMount(SplitOutputTable, { + propsData: { + modelValue: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + }); + + // Assert results are correct + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi'); + expect(rows[0].findAll('td')[1].element.textContent).to.equal('0:00.00'); + expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); + expect(rows[0].findAll('td').length).to.equal(4); + expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 mi'); + expect(rows[1].findAll('td')[1].element.textContent).to.equal('0:00.00'); + expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); + expect(rows[1].findAll('td').length).to.equal(4); + expect(rows[2].findAll('td')[0].element.textContent).to.equal('5 km'); + expect(rows[2].findAll('td')[1].element.textContent).to.equal('0:00.00'); + expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); + expect(rows[2].findAll('td').length).to.equal(4); + expect(rows.length).to.equal(3); +}); + +test('should correctly load split times from split targets', async () => { + // Initialize component + const wrapper = shallowMount(SplitOutputTable, { + propsData: { + modelValue: [ + { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', splitTime: 180 }, + { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', splitTime: 190 }, + { result: 'time', distanceValue: 3000, distanceUnit: 'meters', splitTime: 200 }, + ], + }, + }); + + // Assert results are correct + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); + expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00'); + expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue) + .to.equal(180); + expect(rows[0].findAll('td').length).to.equal(4); + expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); + expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00'); + expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue) + .to.equal(190); + expect(rows[1].findAll('td').length).to.equal(4); + expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m'); + expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00'); + expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue) + .to.equal(200); + expect(rows[2].findAll('td').length).to.equal(4); + expect(rows.length).to.equal(3); +}); + +test('should correctly calculate paces and cumulative times from entered split times', async () => { + // Initialize component + const wrapper = shallowMount(SplitOutputTable, { + propsData: { + modelValue: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + }); + + // Update split times + await wrapper.findAllComponents({ name: 'time-input' })[0].setValue(420); + await wrapper.findAllComponents({ name: 'time-input' })[1].setValue(390); + await wrapper.findAllComponents({ name: 'time-input' })[2].setValue(390); + + // Assert results are correct + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 mi'); + expect(rows[0].findAll('td')[1].element.textContent).to.equal('7:00.00'); + expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }) + .vm.modelValue).to.equal(420); + expect(rows[0].findAll('td')[3].element.textContent).to.equal('4:21 / km'); + expect(rows[0].findAll('td').length).to.equal(4); + expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 mi'); + expect(rows[1].findAll('td')[1].element.textContent).to.equal('13:30.00'); + expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }) + .vm.modelValue).to.equal(390); + expect(rows[1].findAll('td')[3].element.textContent).to.equal('4:02 / km'); + expect(rows[1].findAll('td').length).to.equal(4); + expect(rows[2].findAll('td')[0].element.textContent).to.equal('5 km'); + expect(rows[2].findAll('td')[1].element.textContent).to.equal('20:00.00'); + expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }) + .vm.modelValue).to.equal(390); + expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:39 / km'); + expect(rows[2].findAll('td').length).to.equal(4); + expect(rows.length).to.equal(3); +}); + +test('should correctly update modelValue with split times', async () => { + // Initialize component + const wrapper = shallowMount(SplitOutputTable, { + propsData: { + modelValue: [ + { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', splitTime: 180 }, + { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', splitTime: 180 }, + { result: 'time', distanceValue: 3000, distanceUnit: 'meters', splitTime: 180 }, + ], + }, + }); + + // Update split times + await wrapper.findAllComponents({ name: 'time-input' })[1].setValue(190); + await wrapper.findAllComponents({ name: 'time-input' })[2].setValue(200); + + // Assert modelValue correctly updated + expect(wrapper.vm.modelValue).to.deep.equal([ + { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', splitTime: 180 }, + { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', splitTime: 190 }, + { result: 'time', distanceValue: 3000, distanceUnit: 'meters', splitTime: 200 }, + ]); +}); + +test('should update paces according to default units setting', async () => { + // Initialize component + const wrapper = shallowMount(SplitOutputTable, { + propsData: { + modelValue: [ + { result: 'time', distanceValue: 1, distanceUnit: 'miles', splitTime: 300 }, + { result: 'time', distanceValue: 2, distanceUnit: 'miles', splitTime: 300 }, + { result: 'time', distanceValue: 5, distanceUnit: 'kilometers', splitTime: 330 }, + ], + defaultUnitSystem: 'metric', + } + }); + + // Assert paces are correct + let rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:06 / km'); + expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:06 / km'); + expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:05 / km'); + + // Change default units + await wrapper.setProps({ defaultUnitSystem: 'imperial' }); + + // Assert paces are correct + rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[3].element.textContent).to.equal('5:00 / mi'); + expect(rows[1].findAll('td')[3].element.textContent).to.equal('5:00 / mi'); + expect(rows[2].findAll('td')[3].element.textContent).to.equal('4:58 / mi'); +}); diff --git a/tests/unit/components/TargetEditor.spec.js b/tests/unit/components/TargetEditor.spec.js @@ -2,18 +2,19 @@ import { test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import TargetEditor from '@/components/TargetEditor.vue'; -test('should correctly render target set', async () => { +test('should correctly render standard target set', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { modelValue: { name: 'My target set', targets: [ - { distanceUnit: 'kilometers', distanceValue: 1.61, result: 'time' }, - { distanceUnit: 'miles', distanceValue: 3.11, result: 'time' }, - { time: 600, result: 'distance' }, + { distanceUnit: 'kilometers', distanceValue: 1.61, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 3.11, type: 'distance' }, + { time: 600, type: 'time' }, ], }, + setType: 'standard', }, }); @@ -28,6 +29,76 @@ test('should correctly render target set', async () => { expect(rows.length).to.equal(3); }); +test('should correctly render split target set', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'kilometers', distanceValue: 1.61, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 3.11, type: 'distance' }, + ], + }, + setType: 'split', + }, + }); + + // Assert target set correctly rendered + expect(wrapper.find('input').element.value).to.equal('My target set'); + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.61); + expect(rows[0].find('select').element.value).to.equal('kilometers'); + expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3.11); + expect(rows[1].find('select').element.value).to.equal('miles'); + expect(rows.length).to.equal(2); +}); + +test('should correctly render workout target set', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + distanceUnit: 'kilometers', distanceValue: 5, + splitUnit: 'miles', splitValue: 1, + type: 'distance' + }, + ], + }, + setType: 'workout', + }, + }); + + // Assert target set correctly rendered + expect(wrapper.find('input').element.value).to.equal('My target set'); + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(400); + expect(rows[0].findAll('select')[0].element.value).to.equal('meters'); + expect(rows[0].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(2); + expect(rows[0].findAll('select')[1].element.value).to.equal('miles'); + expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(2); + expect(rows[1].find('select').element.value).to.equal('kilometers'); + expect(rows[1].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(6000); + expect(rows[2].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(1); + expect(rows[2].findAll('select')[0].element.value).to.equal('miles'); + expect(rows[2].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(5); + expect(rows[2].findAll('select')[1].element.value).to.equal('kilometers'); + expect(rows.length).to.equal(3); +}); + test('revert button should emit revert event', async () => { // Initialize component const wrapper = shallowMount(TargetEditor); @@ -65,17 +136,18 @@ test('close button should emit close event', async () => { expect(wrapper.emitted().close.length).to.equal(1); }); -test('add distance target button should correctly add imperial distance target', async () => { +test('add distance target button should correctly add standard imperial distance target', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { modelValue: { name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, - { time: 0, result: 'distance' }, + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + { time: 0, type: 'time' }, ], }, + setType: 'standard', defaultUnitSystem: 'imperial' }, }); @@ -88,25 +160,26 @@ test('add distance target button should correctly add imperial distance target', [{ name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, - { time: 0, result: 'distance' }, - { distanceUnit: 'miles', distanceValue: 1, result: 'time'}, + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + { time: 0, type: 'time' }, + { distanceUnit: 'miles', distanceValue: 1, type: 'distance'}, ], }], ]); }); -test('add distance target button should correctly add metric distance target', async () => { +test('add distance target button should correctly add standard metric distance target', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { modelValue: { name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, - { time: 0, result: 'distance' }, + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + { time: 0, type: 'time' }, ], }, + setType: 'standard', defaultUnitSystem: 'metric' }, }); @@ -119,25 +192,190 @@ test('add distance target button should correctly add metric distance target', a [{ name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, - { time: 0, result: 'distance' }, - { distanceUnit: 'kilometers', distanceValue: 1, result: 'time'}, + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + { time: 0, type: 'time' }, + { distanceUnit: 'kilometers', distanceValue: 1, type: 'distance'}, ], }], ]); }); -test('add time target button should correctly add time target', async () => { +test('add distance target button should correctly add split imperial distance target', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { modelValue: { name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, - { time: 0, result: 'distance' }, + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, ], }, + setType: 'split', + defaultUnitSystem: 'imperial' + }, + }); + + // Add distance target + await wrapper.find('button[title="Add distance target"]').trigger('click'); + + // Assert input event was emitted + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [{ + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 1, type: 'distance'}, + ], + }], + ]); +}); + +test('add distance target button should correctly add split metric distance target', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + ], + }, + setType: 'split', + defaultUnitSystem: 'metric' + }, + }); + + // Add distance target + await wrapper.find('button[title="Add distance target"]').trigger('click'); + + // Assert input event was emitted + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [{ + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + { distanceUnit: 'kilometers', distanceValue: 1, type: 'distance'}, + ], + }], + ]); +}); + +test('add distance target button should correctly add workout imperial distance target', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + ], + }, + setType: 'workout', + defaultUnitSystem: 'imperial' + }, + }); + + // Add distance target + await wrapper.find('button[title="Add distance target"]').trigger('click'); + + // Assert input event was emitted + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [{ + name: 'My target set', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + distanceUnit: 'miles', distanceValue: 1, + splitUnit: 'miles', splitValue: 1, + type: 'distance' + }, + ], + }], + ]); +}); + +test('add distance target button should correctly add workout metric distance target', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + ], + }, + setType: 'workout', + defaultUnitSystem: 'metric' + }, + }); + + // Add distance target + await wrapper.find('button[title="Add distance target"]').trigger('click'); + + // Assert input event was emitted + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [{ + name: 'My target set', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + distanceUnit: 'kilometers', distanceValue: 1, + splitUnit: 'kilometers', splitValue: 1, + type: 'distance' + }, + ], + }], + ]); +}); + +test('add time target button should correctly add standard time target', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + { time: 0, type: 'time' }, + ], + }, + setType: 'standard', }, }); @@ -148,22 +386,145 @@ test('add time target button should correctly add time target', async () => { expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ [{ name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 0, result: 'time' }, - { time: 0, result: 'distance' }, - { time: 600, result: 'distance' }, + { distanceUnit: 'miles', distanceValue: 0, type: 'distance' }, + { time: 0, type: 'time' }, + { time: 600, type: 'time' }, + ], + }], + ]); +}); + +test('add time target button should be hidden for split target sets', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 1, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 2, type: 'distance' }, + ], + }, + setType: 'split', + }, + }); + + // Add time target + expect(wrapper.findAll('button[title="Add time target"]')).toHaveLength(0); +}); + +test('add time target button should correctly add workout imperial time target', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + ], + }, + setType: 'workout', + defaultUnitSystem: 'imperial' + }, + }); + + // Add distance target + await wrapper.find('button[title="Add time target"]').trigger('click'); + + // Assert input event was emitted + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [{ + name: 'My target set', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + time: 600, + splitUnit: 'miles', splitValue: 1, + type: 'time' + }, ], }], ]); }); -test('Should emit input event when targets are updated', async () => { +test('add time target button should correctly add workout metric time target', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { modelValue: { name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + ], + }, + setType: 'workout', + defaultUnitSystem: 'metric' + }, + }); + + // Add distance target + await wrapper.find('button[title="Add time target"]').trigger('click'); + + // Assert input event was emitted + expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ + [{ + name: 'My target set', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + time: 600, + splitUnit: 'kilometers', splitValue: 1, + type: 'time' + }, + ], + }], + ]); +}); + +test('should emit input event when targets are updated', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { distanceUnit: 'miles', distanceValue: 2, type: 'distance' }, ], }, }, @@ -178,21 +539,21 @@ test('Should emit input event when targets are updated', async () => { { name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, ], }, ], ]); }); -test('Should emit input event when target set name is updated', async () => { +test('should emit input event when target set name is updated', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { modelValue: { name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 2, type: 'distance' }, ], }, }, @@ -207,7 +568,7 @@ test('Should emit input event when target set name is updated', async () => { { name: 'My target set #2', targets: [ - { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 2, type: 'distance' }, ], }, ], @@ -221,9 +582,9 @@ test('removeTarget button should correctly remove target', async () => { modelValue: { name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, - { distanceUnit: 'miles', distanceValue: 2, result: 'time' }, - { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 1, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 2, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, ], }, }, @@ -237,9 +598,26 @@ test('removeTarget button should correctly remove target', async () => { [{ name: 'My target set', targets: [ - { distanceUnit: 'miles', distanceValue: 1, result: 'time' }, - { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + { distanceUnit: 'miles', distanceValue: 1, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, ], }], ]); }); + +test('should display message when target set is empty', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [], + }, + }, + }); + + // Assert message correctly rendered + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].text()).to.equal('There aren\'t any targets in this set yet.'); + expect(rows.length).to.equal(1); +}); diff --git a/tests/unit/components/TargetSetSelector.spec.js b/tests/unit/components/TargetSetSelector.spec.js @@ -1,37 +1,30 @@ -import { beforeEach, test, expect, vi } from 'vitest'; +import { test, expect, vi } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; -beforeEach(() => { - localStorage.clear(); -}) - test('should correctly render target sets options', async () => { - // Initialize localStorage - const targetSets = { - 'A': { - name: '1st target set', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - ], - }, - 'B': { - name: '2nd target set', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, - ], - }, - }; - localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets)); - // Initialize component const wrapper = shallowMount(TargetSetSelector, { propsData: { - modelValue: 'B', + selectedTargetSet: 'B', + targetSets: { + 'A': { + name: '1st target set', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + 'B': { + name: '2nd target set', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, + ], + }, + }, } }); @@ -50,86 +43,110 @@ test('should correctly render target sets options', async () => { }); test('Create New Target Set option should correctly add target set', async () => { - // Initialize localStorage + // Initialize component let targetSets = { 'A': { name: '1st target set', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, ], }, 'B': { name: '2nd target set', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, ], }, }; - localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets)); - - // Initialize component const wrapper = shallowMount(TargetSetSelector, { propsData: { - modelValue: 'A', + selectedTargetSet: '_new', + targetSets, } }); + await wrapper.vm.$nextTick(); - // Add target set - await wrapper.find('select').setValue('_new'); + // Assert new target set selected (key is unix timestamp in milliseconds) + const key1 = wrapper.find('select').element.value; + expect(parseInt(key1)).to.be.closeTo(parseInt(Date.now().toString()), 1000); // Assert target set options were correctly updated - const options = wrapper.findAll('option'); + let options = wrapper.findAll('option'); expect(options[0].element.text).to.equal('1st target set'); expect(options[0].element.value).to.equal('A'); expect(options[1].element.text).to.equal('2nd target set'); expect(options[1].element.value).to.equal('B'); expect(options[2].element.text).to.equal('New target set'); - expect(options[2].element.value).to.match(/\d{12,14}/); + expect(options[2].element.value).to.equal(key1) expect(options[3].element.text).to.equal('[ Create New Target Set ]'); expect(options[3].element.value).to.equal('_new'); expect(options.length).to.equal(4); // Assert target sets were correctly updated - targetSets[options[2].element.value] = { + targetSets[key1] = { name: 'New target set', targets: [], }; - expect(localStorage.getItem('running-tools.target-sets')).to.equal(JSON.stringify(targetSets)); + expect(wrapper.vm.targetSets).to.deep.equal(targetSets); + + // Add another target set + await wrapper.find('select').setValue('_new'); - // Assert targets-updated event was emitted - expect(wrapper.emitted()['targets-updated'].length).to.equal(1); + // Assert new target set selected (key is unix timestamp in milliseconds) + const key2 = wrapper.find('select').element.value; + expect(parseInt(key2)).to.be.closeTo(parseInt(Date.now().toString()), 1000); + expect(key2).to.not.equal(key1); + + // Assert target set options were correctly updated + options = wrapper.findAll('option'); + expect(options[0].element.text).to.equal('1st target set'); + expect(options[0].element.value).to.equal('A'); + expect(options[1].element.text).to.equal('2nd target set'); + expect(options[1].element.value).to.equal('B'); + expect(options[2].element.text).to.equal('New target set'); + expect(options[2].element.value).to.equal(key1) + expect(options[3].element.text).to.equal('New target set'); + expect(options[3].element.value).to.equal(key2); + expect(options[4].element.text).to.equal('[ Create New Target Set ]'); + expect(options[4].element.value).to.equal('_new'); + expect(options.length).to.equal(5); + + // Assert target sets were correctly updated + targetSets[key2] = { + name: 'New target set', + targets: [], + }; + expect(wrapper.vm.targetSets).to.deep.equal(targetSets); }); test('Revert event should correctly reset a default target set', async () => { - // Initialize localStorage + // Initialize component let targetSets = { '_split_targets': { name: '1st target set', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, ], }, '1234567890123': { name: '2nd target set', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, ], }, }; - localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets)); - - // Initialize component const wrapper = shallowMount(TargetSetSelector, { propsData: { - modelValue: '_split_targets', + selectedTargetSet: '_split_targets', + targetSets, } }); @@ -149,42 +166,37 @@ test('Revert event should correctly reset a default target set', async () => { // Assert target sets were correctly updated targetSets._split_targets.name = '5K Mile Splits'; targetSets._split_targets.targets[2] = { - result: 'time', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', }; - expect(localStorage.getItem('running-tools.target-sets')).to.equal(JSON.stringify(targetSets)); - - // Assert targets-updated event was emitted - expect(wrapper.emitted()['targets-updated'].length).to.equal(1); + expect(wrapper.vm.targetSets).to.deep.equal(targetSets); }); test('Revert event should correctly delete a custom target set', async () => { - // Initialize localStorage + // Initialize component let targetSets = { '_split_targets': { name: '1st target set', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, ], }, '1234567890123': { name: '2nd target set', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, ], }, }; - localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets)); - - // Initialize component const wrapper = shallowMount(TargetSetSelector, { propsData: { - modelValue: '1234567890123', + selectedTargetSet: '1234567890123', + targetSets, } }); @@ -201,36 +213,31 @@ test('Revert event should correctly delete a custom target set', async () => { // Assert target sets were correctly updated delete targetSets['1234567890123']; - expect(localStorage.getItem('running-tools.target-sets')).to.equal(JSON.stringify(targetSets)); - - // Assert targets-updated event was emitted - expect(wrapper.emitted()['targets-updated'].length).to.equal(1); + expect(wrapper.vm.targetSets).to.deep.equal(targetSets); }); test('edit button should open target editor with the correct props for default set', async () => { - // Initialize localStorage + // Initialize component const targetSets = { '_split_targets': { name: '5K Mile Splits', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, ], }, }; - localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets)); - - // Initialize component const wrapper = shallowMount(TargetSetSelector, { propsData: { - modelValue: '_split_targets', + selectedTargetSet: '_split_targets', + targetSets, defaultUnitSystem: 'fake-unit-system', } }); // Mock showModal function - wrapper.vm.$refs.dialog.showModal = vi.fn(); + wrapper.vm.dialogElement.showModal = vi.fn(); // Click edit button await wrapper.find('button').trigger('click'); @@ -243,29 +250,27 @@ test('edit button should open target editor with the correct props for default s }); test('edit button should open target editor with the correct props for custom set', async () => { - // Initialize localStorage + // Initialize component const targetSets = { '1234567890123': { name: '2nd target set', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 10, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, ], }, }; - localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets)); - - // Initialize component const wrapper = shallowMount(TargetSetSelector, { propsData: { - modelValue: '1234567890123', + selectedTargetSet: '1234567890123', + targetSets, defaultUnitSystem: 'fake-unit-system', } }); // Mock showModal function - wrapper.vm.$refs.dialog.showModal = vi.fn(); + wrapper.vm.dialogElement.showModal = vi.fn(); // Click edit button await wrapper.find('button').trigger('click'); @@ -277,48 +282,57 @@ test('edit button should open target editor with the correct props for custom se expect(targetEditor.vm.defaultUnitSystem).to.equal('fake-unit-system'); }); -test('should reload and sort target set before target editor is opened', async () => { - // Initialize localStorage +test('should sort target set after target editor is closed', async () => { + // Initialize component let targetSets = { '_split_targets': { name: '5K Mile Splits', - targets: [ - { result: 'distance', timeValue: 60 }, - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - ], + targets: [], }, }; - localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets)); - - // Initialize component const wrapper = shallowMount(TargetSetSelector, { propsData: { - modelValue: '_split_targets', + selectedTargetSet: '_split_targets', + targetSets, } }); - // Update localStorage - targetSets._split_targets.name = '5K Mile Splits #2'; - localStorage.setItem('running-tools.target-sets', JSON.stringify(targetSets)); + // Mock modal close function + wrapper.vm.dialogElement.close = vi.fn(); - // Mock showModal function - wrapper.vm.$refs.dialog.showModal = vi.fn(); - - // Click edit button - await wrapper.find('button').trigger('click'); + // Update targets and trigger close event + await wrapper.findComponent({ name: 'target-editor' }).setValue({ + name: '5K Mile Splits', + targets: [ + { type: 'time', timeValue: 60 }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + ], + }); + await wrapper.findComponent({ name: 'target-editor' }).vm.$emit('close'); // Assert target set was sorted expect(wrapper.findComponent({ name: 'target-editor' }).vm.modelValue).to.deep.equal({ - name: '5K Mile Splits #2', + name: '5K Mile Splits', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - { result: 'distance', timeValue: 60 }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'time', timeValue: 60 }, ], }); }); + +test('should correctly pass setType prop to TargetEditor', async () => { + const wrapper = shallowMount(TargetSetSelector, { + propsData: { + setType: 'foo' + } + }); + + // Assert target editor props are correct + expect(wrapper.findComponent({ name: 'target-editor' }).vm.setType).to.equal('foo'); +}); diff --git a/tests/unit/components/TimeInput.spec.js b/tests/unit/components/TimeInput.spec.js @@ -42,13 +42,13 @@ test('should emit input event when value changes', async () => { const wrapper = shallowMount(TimeInput); // Change value to 1:00:00.00 - await wrapper.setData({ internalValue: 3600 }); + await wrapper.findAllComponents({ name: 'integer-input' })[0].setValue(1); // Assert input event was emitted expect(wrapper.emitted()['update:modelValue']).to.deep.equal([[3600.00]]); // Change value to 1:00:01.50 - await wrapper.setData({ internalValue: 3601.5 }); + await wrapper.findComponent({ name: 'decimal-input' }).setValue(1.5); // Assert another input event was emitted expect(wrapper.emitted()['update:modelValue']).to.deep.equal([[3600.00], [3601.50]]); diff --git a/tests/unit/utils/calculators.spec.js b/tests/unit/utils/calculators.spec.js @@ -0,0 +1,201 @@ +import { test, expect } from 'vitest'; +import * as calculatorUtils from '@/utils/calculators'; + +test('should correctly calculate pace times', () => { + const input = { + distanceValue: 1, + distanceUnit: 'kilometers', + time: 100, + }; + const target = { + distanceValue: 20, + distanceUnit: 'meters', + type: 'distance', + }; + + const result = calculatorUtils.calculatePaceResults(input, target, 'metric'); + + expect(result).to.deep.equal({ + key: '20 m', + value: '0:02.00', + pace: '1:40 / km', + result: 'value', + sort: 2, + }); +}); + +test('should correctly calculate pace distances according to default units setting', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 1200, + }; + const target = { + time: 600, + type: 'time', + }; + + const result1 = calculatorUtils.calculatePaceResults(input, target, 'metric'); + const result2 = calculatorUtils.calculatePaceResults(input, target, 'imperial'); + + expect(result1.key).to.equal('1.61 km'); + expect(result1.value).to.equal('10:00'); + expect(result1.pace).to.equal('6:13 / km'); + expect(result1.result).to.equal('key'); + expect(result1.sort).to.be.closeTo(600, 0.01); + + expect(result2.key).to.equal('1.00 mi'); + expect(result2.value).to.equal('10:00'); + expect(result2.pace).to.equal('10:00 / mi'); + expect(result2.result).to.equal('key'); + expect(result2.sort).to.be.closeTo(600, 0.01); +}); + +test('should correctly predict race times', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + distanceValue: 10, + distanceUnit: 'kilometers', + type: 'distance', + }; + const options = { + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result.key).to.equal('10 km'); + expect(result.value).to.equal('41:34.80'); + expect(result.pace).to.equal('6:42 / mi'); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(2494.80, 0.01); +}); + +test('should correctly calculate race distances according to default units setting', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + time: 2495, + type: 'time', + }; + const options = { + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result1 = calculatorUtils.calculateRaceResults(input, target, options, 'metric'); + const result2 = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result1.key).to.equal('10.00 km'); + expect(result1.value).to.equal('41:35'); + expect(result1.pace).to.equal('4:09 / km'); + expect(result1.result).to.equal('key'); + expect(result1.sort).to.equal(2495); + + expect(result2.key).to.equal('6.21 mi'); + expect(result2.value).to.equal('41:35'); + expect(result2.pace).to.equal('6:41 / mi'); + expect(result2.result).to.equal('key'); + expect(result2.sort).to.equal(2495); +}); + +test('should correctly predict race times according to race options', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target = { + distanceValue: 5, + distanceUnit: 'kilometers', + type: 'distance', + }; + const options = { + model: 'RiegelModel', + riegelExponent: 1.12, + } + + const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result.key).to.equal('5 km'); + expect(result.value).to.equal('17:11.77'); + expect(result.pace).to.equal('5:32 / mi'); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(1031.77, 0.01); +}); + +test('should correctly calculate race statistics', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + + const results = calculatorUtils.calculateRaceStats(input); + + expect(results.purdyPoints).to.be.closeTo(454.5, 0.1); + expect(results.vo2).to.be.closeTo(47.4, 0.1); + expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1); + expect(results.vo2Max).to.be.closeTo(49.8, 0.1); +}); + +test('should correctly calculate distance-based workouts according to race options', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target = { + distanceValue: 5, + distanceUnit: 'kilometers', // 5k split is ~17:11.77 + splitValue: 1000, + splitUnit: 'meters', + type: 'distance', + }; + const options = { + model: 'RiegelModel', + riegelExponent: 1.12, + } + + const result = calculatorUtils.calculateWorkoutResults(input, target, options); + + expect(result.key).to.equal('1000 m @ 5 km'); + expect(result.value).to.equal('3:26.35'); + expect(result.pace).to.equal(''); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(206.35, 0.01); +}); + +test('should correctly calculate time-based workouts', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + time: 2495, // ~10k split is 41:35 + splitValue: 1, + splitUnit: 'miles', + type: 'time', + }; + const options = { + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result = calculatorUtils.calculateWorkoutResults(input, target, options); + + expect(result.key).to.equal('1 mi @ 41:35'); + expect(result.value).to.equal('6:41.50'); + expect(result.pace).to.equal(''); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(401.50, 0.01); +}); diff --git a/tests/unit/utils/format.spec.js b/tests/unit/utils/format.spec.js @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import formatUtils from '@/utils/format'; +import * as formatUtils from '@/utils/format'; describe('formatNumber method', () => { test('should correctly format number when padding is not 0', () => { diff --git a/tests/unit/utils/paces.spec.js b/tests/unit/utils/paces.spec.js @@ -1,20 +1,14 @@ import { describe, test, expect } from 'vitest'; -import paces from '@/utils/paces'; +import * as paces from '@/utils/paces'; -describe('getPace method', () => { - test('2 meters in 6 seconds should equal 3 seconds per meter', () => { - expect(paces.getPace(2, 6)).to.equal(3); +describe('calculateTime method', () => { + test('1 meters in 3 seconds should equal 2 meters in 6 seconds', () => { + expect(paces.calculateTime(1, 3, 2)).to.equal(6); }); }); -describe('getTime method', () => { - test('2 meters at 3 seconds per meter should equal 6 seconds', () => { - expect(paces.getTime(3, 2)).to.equal(6); - }); -}); - -describe('getDistance method', () => { - test('6 seconds at 3 seconds per meter should equal 2 meters', () => { - expect(paces.getDistance(3, 6)).to.equal(2); +describe('calculateDistance method', () => { + test('1 meter in 3 seconds should equal 2 meters in 6 seconds', () => { + expect(paces.calculateDistance(3, 1, 6)).to.equal(2); }); }); diff --git a/tests/unit/utils/races.spec.js b/tests/unit/utils/races.spec.js @@ -1,172 +1,166 @@ import { describe, test, expect } from 'vitest'; -import raceUtils from '@/utils/races'; +import * as raceUtils from '@/utils/races'; -describe('PurdyPointsModel', () => { - describe('getPurdyPoints method', () => { - test('Result should be approximately correct', () => { - const result = raceUtils.PurdyPointsModel.getPurdyPoints(5000, 1200); - expect(result).to.be.closeTo(454, 1); +describe('predictTime method', () => { + describe('PredictTime method', () => { + test('Average Model', () => { + const riegel = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + const cameron = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + const purdyPoints = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + const vo2Max = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; + + const result = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + expect(result).to.equal(expected); + }); + + test('Should predict identical times for itentical distances', () => { + const result = raceUtils.predictTime(5000, 1200, 5000, 'AverageModel'); + expect(result).to.be.closeTo(1200, 0.001); }); }); - describe('PredictTime method', () => { + describe('Purdy Points Model', () => { test('Predictions should be approximately correct', () => { - const result = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); + const result = raceUtils.predictTime(5000, 1200, 10000, 'PurdyPointsModel'); expect(result).to.be.closeTo(2490, 1); }); test('Should predict identical times for itentical distances', () => { - const result = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 5000); + const result = raceUtils.predictTime(5000, 1200, 5000, 'PurdyPointsModel'); expect(result).to.be.closeTo(1200, 0.001); }); }); - describe('PredictDistance method', () => { + describe('VO2 Max Model', () => { test('Predictions should be approximately correct', () => { - const result = raceUtils.PurdyPointsModel.predictDistance(1200, 5000, 2490); - expect(result).to.be.closeTo(10000, 10); + const result = raceUtils.predictTime(5000, 1200, 10000, 'VO2MaxModel'); + expect(result).to.be.closeTo(2488, 1); }); test('Should predict identical times for itentical distances', () => { - const result = raceUtils.PurdyPointsModel.predictDistance(1200, 5000, 1200); - expect(result).to.be.closeTo(5000, 0.001); - }); - }); -}); - -describe('VO2MaxModel', () => { - describe('getVO2 method', () => { - test('Result should be approximately correct', () => { - const result = raceUtils.VO2MaxModel.getVO2(5000, 1200); - expect(result).to.be.closeTo(47.4, 0.1); + const result = raceUtils.predictTime(5000, 1200, 5000, 'VO2MaxModel'); + expect(result).to.be.closeTo(1200, 0.001); }); }); - describe('getVO2Percentage method', () => { - test('Result should be approximately correct', () => { - const result = raceUtils.VO2MaxModel.getVO2Percentage(660); - expect(result).to.be.closeTo(1, 0.001); + describe('Cameron Model', () => { + test('Predictions should be approximately correct', () => { + const result = raceUtils.predictTime(5000, 1200, 10000, 'CameronModel'); + expect(result).to.be.closeTo(2500, 1); }); - }); - describe('getVO2Max method', () => { - test('Result should be approximately correct', () => { - const result = raceUtils.VO2MaxModel.getVO2Max(5000, 1200); - expect(result).to.be.closeTo(49.8, 0.1); + test('Should predict identical times for itentical distances', () => { + const result = raceUtils.predictTime(5000, 1200, 5000, 'CameronModel'); + expect(result).to.be.closeTo(1200, 0.001); }); }); - describe('PredictTime method', () => { + describe('Riegel Model', () => { test('Predictions should be approximately correct', () => { - const result = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); - expect(result).to.be.closeTo(2488, 1); + const result = raceUtils.predictTime(5000, 1200, 10000, 'RiegelModel'); + expect(result).to.be.closeTo(2502, 1); }); test('Should predict identical times for itentical distances', () => { - const result = raceUtils.VO2MaxModel.predictTime(5000, 1200, 5000); + const result = raceUtils.predictTime(5000, 1200, 5000, 'RiegelModel'); expect(result).to.be.closeTo(1200, 0.001); }); }); +}); - describe('PredictDistance method', () => { - test('Predictions should be approximately correct', () => { - const result = raceUtils.VO2MaxModel.predictDistance(1200, 5000, 2488); +describe('predictDistance method', () => { + describe('Average Model', () => { + test('Predictions should be correct', () => { + const riegel = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + const cameron = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + const purdyPoints = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + const vo2Max = raceUtils.predictTime(5000, 1200, 10000, 'AverageModel'); + const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; + + const result = raceUtils.predictDistance(1200, 5000, expected); expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = raceUtils.VO2MaxModel.predictDistance(1200, 5000, 1200); + const result = raceUtils.predictDistance(1200, 5000, 1200, 'AverageModel'); expect(result).to.be.closeTo(5000, 0.001); }); }); -}); -describe('CameronModel', () => { - describe('PredictTime method', () => { + describe('Purdy Points Model', () => { test('Predictions should be approximately correct', () => { - const result = raceUtils.CameronModel.predictTime(5000, 1200, 10000); - expect(result).to.be.closeTo(2500, 1); + const result = raceUtils.predictDistance(1200, 5000, 2490, 'PurdyPointsModel'); + expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = raceUtils.CameronModel.predictTime(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + const result = raceUtils.predictDistance(1200, 5000, 1200, 'PurdyPointsModel'); + expect(result).to.be.closeTo(5000, 0.001); }); }); - describe('PredictDistance method', () => { + describe('VO2 Max Model', () => { test('Predictions should be approximately correct', () => { - const result = raceUtils.CameronModel.predictDistance(1200, 5000, 2500); + const result = raceUtils.predictDistance(1200, 5000, 2488, 'VO2MaxModel'); expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = raceUtils.CameronModel.predictDistance(1200, 5000, 1200); + const result = raceUtils.predictDistance(1200, 5000, 1200, 'VO2MaxModel'); expect(result).to.be.closeTo(5000, 0.001); }); }); -}); -describe('RiegelModel', () => { - describe('PredictTime method', () => { + describe('Cameron Model', () => { test('Predictions should be approximately correct', () => { - const result = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); - expect(result).to.be.closeTo(2502, 1); + const result = raceUtils.predictDistance(1200, 5000, 2500, 'CameronModel'); + expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = raceUtils.RiegelModel.predictTime(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); + const result = raceUtils.predictDistance(1200, 5000, 1200, 'CameronModel'); + expect(result).to.be.closeTo(5000, 0.001); }); }); - describe('PredictDistance method', () => { + describe('Riegel Model', () => { test('Predictions should be approximately correct', () => { - const result = raceUtils.RiegelModel.predictDistance(1200, 5000, 2502); + const result = raceUtils.predictDistance(1200, 5000, 2502, 'RiegelModel'); expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = raceUtils.RiegelModel.predictDistance(1200, 5000, 1200); + const result = raceUtils.predictDistance(1200, 5000, 1200, 'RiegelModel'); expect(result).to.be.closeTo(5000, 0.001); }); }); }); -describe('AverageModel', () => { - describe('PredictTime method', () => { - test('Predictions should be correct', () => { - const riegel = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); - const cameron = raceUtils.CameronModel.predictTime(5000, 1200, 10000); - const purdyPoints = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); - const vo2Max = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); - const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; - - const result = raceUtils.AverageModel.predictTime(5000, 1200, 10000); - expect(result).to.equal(expected); - }); - - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.AverageModel.predictTime(5000, 1200, 5000); - expect(result).to.be.closeTo(1200, 0.001); - }); +describe('getVO2 method', () => { + test('Result should be approximately correct', () => { + const result = raceUtils.getVO2(5000, 1200); + expect(result).to.be.closeTo(47.4, 0.1); }); +}); - describe('PredictDistance method', () => { - test('Predictions should be correct', () => { - const riegel = raceUtils.RiegelModel.predictTime(5000, 1200, 10000); - const cameron = raceUtils.CameronModel.predictTime(5000, 1200, 10000); - const purdyPoints = raceUtils.PurdyPointsModel.predictTime(5000, 1200, 10000); - const vo2Max = raceUtils.VO2MaxModel.predictTime(5000, 1200, 10000); - const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; +describe('getVO2Percentage method', () => { + test('Result should be approximately correct', () => { + const result = raceUtils.getVO2Percentage(660); + expect(result).to.be.closeTo(1, 0.001); + }); +}); - const result = raceUtils.AverageModel.predictDistance(1200, 5000, expected); - expect(result).to.be.closeTo(10000, 10); - }); +describe('getVO2Max method', () => { + test('Result should be approximately correct', () => { + const result = raceUtils.getVO2Max(5000, 1200); + expect(result).to.be.closeTo(49.8, 0.1); + }); +}); - test('Should predict identical times for itentical distances', () => { - const result = raceUtils.AverageModel.predictDistance(1200, 5000, 1200); - expect(result).to.be.closeTo(5000, 0.001); - }); +describe('getPurdyPoints method', () => { + test('Result should be approximately correct', () => { + const result = raceUtils.getPurdyPoints(5000, 1200); + expect(result).to.be.closeTo(454, 1); }); }); diff --git a/tests/unit/utils/targets.spec.js b/tests/unit/utils/targets.spec.js @@ -1,18 +1,18 @@ import { describe, test, expect } from 'vitest'; -import targets from '@/utils/targets'; +import * as targets from '@/utils/targets'; describe('sort method', () => { test('should correctly sort targets', () => { // Initialize unsorted and sorted targets const input = [ - { time: 60, result: 'distance' }, - { distanceUnit: 'kilometers', distanceValue: 5, result: 'time' }, - { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, + { time: 60, type: 'time' }, + { distanceUnit: 'kilometers', distanceValue: 5, type: 'distance' }, + { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, ]; const expected = [ - { distanceUnit: 'miles', distanceValue: 3, result: 'time' }, - { distanceUnit: 'kilometers', distanceValue: 5, result: 'time' }, - { time: 60, result: 'distance' }, + { distanceUnit: 'miles', distanceValue: 3, type: 'distance' }, + { distanceUnit: 'kilometers', distanceValue: 5, type: 'distance' }, + { time: 60, type: 'time' }, ]; // Assert sort method sorts targets correctly diff --git a/tests/unit/utils/units.spec.js b/tests/unit/utils/units.spec.js @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest'; -import units from '@/utils/units'; +import * as units from '@/utils/units'; describe('convertTime method', () => { test('90 seconds should equal 1.5 minutes', () => { diff --git a/tests/unit/views/BatchCalculator.spec.js b/tests/unit/views/BatchCalculator.spec.js @@ -0,0 +1,419 @@ +import { beforeEach, test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import BatchCalculator from '@/views/BatchCalculator.vue'; + +beforeEach(() => { + localStorage.clear(); +}) + +test('should load input from localStorage', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.batch-calculator-input', JSON.stringify({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + })); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert options loaded + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }); +}); + +test('should save input to localStorage when modified', async () => { + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Update input pace + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }); + + // Assert input saved + expect(localStorage.getItem('running-tools.batch-calculator-input')).to.equal(JSON.stringify({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + })); +}); + +test('should load batch options from localStorage', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.batch-calculator-options', JSON.stringify({ + calculator: 'race', + increment: 32, + rows: 15, + })); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert options loaded + expect(wrapper.find('select[aria-label="Calculator"]').element.value).to.equal('race'); + expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(32); + expect(wrapper.findComponent({ name: 'integer-input' }).vm.modelValue).to.equal(15); +}); + +test('should save batch options to localStorage when modified', async () => { + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Update active calculator + await wrapper.find('select[aria-label="Calculator"]').setValue('race'); + + // Assert options saved + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ + calculator: 'race', + increment: 15, + rows: 20, + })); + + // Update increment value + await wrapper.findComponent({ name: 'time-input' }).setValue(32); + + // Assert options saved + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ + calculator: 'race', + increment: 32, + rows: 20, + })); + + // Update number of rows + await wrapper.findComponent({ name: 'integer-input' }).setValue(15); + + // Assert options saved + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ + calculator: 'race', + increment: 32, + rows: 15, + })); +}); + +test('should load default units setting from localStorage', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.default-unit-system', '"metric"'); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert default units setting loaded + expect(wrapper.find('select[aria-label="Default units"]').element.value).to.equal('metric'); + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.defaultUnitSystem) + .to.equal('metric'); +}); + +test('should save default units setting from localStorage when modified', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.default-unit-system', '"metric"'); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Change default units setting + await wrapper.find('select[aria-label="Default units"]').setValue('imperial'); + + // New default units should be saved to localStorage + expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"'); +}); + +test('should load selected target set from localStorage', async () => { + // Initialize localStorage + const selectedTargetSets = [ + { + name: 'Pace targets #1', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + ], + }, + { + name: 'Race targets #1', + targets: [ + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + { + name: 'Workout targets #1', + targets: [ + { + type: 'distance', distanceValue: 5, distanceUnit: 'miles', + splitValue: 1, splitUnit: 'miles' + }, + ], + }, + ]; + localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify({ + 'A': selectedTargetSets[0], + 'B': { + name: 'Pace targets #2', + targets: [ + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.pace-calculator-target-set', '"A"'); + localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({ + 'C': selectedTargetSets[1], + 'D': { + name: 'Race targets #2', + targets: [ + { type: 'distance', distanceValue: 4, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.race-calculator-target-set', '"C"'); + localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({ + 'E': selectedTargetSets[2], + 'F': { + name: 'Workout targets #2', + targets: [ + { type: 'distance', distanceValue: 6, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.workout-calculator-target-set', '"E"'); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert selected pace target set is loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('A'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[0].targets); + + // Assert selected race target set is loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('race'); + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('C'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[1].targets); + + // Assert selected workout target set is loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('E'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[2].targets); +}); + +test('should save selected target set to localStorage when modified', async () => { + // Initialize localStorage + const selectedTargetSets = [ + { + name: 'Pace targets #1', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + ], + }, + { + name: 'Race targets #1', + targets: [ + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + { + name: 'Workout targets #1', + targets: [ + { + type: 'distance', distanceValue: 5, distanceUnit: 'miles', + splitValue: 1, splitUnit: 'miles' + }, + ], + }, + ]; + localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify({ + 'A': selectedTargetSets[0], + 'B': { + name: 'Pace targets #2', + targets: [ + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.pace-calculator-target-set', '"B"'); + localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({ + 'C': selectedTargetSets[1], + 'D': { + name: 'Race targets #2', + targets: [ + { type: 'distance', distanceValue: 4, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.race-calculator-target-set', '"D"'); + localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({ + 'E': selectedTargetSets[2], + 'F': { + name: 'Workout targets #2', + targets: [ + { type: 'distance', distanceValue: 6, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.workout-calculator-target-set', '"F"'); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Update selected pace target set + await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); + await wrapper.findComponent({ name: 'target-set-selector' }).setValue('A', 'selectedTargetSet'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[0].targets); + expect(localStorage.getItem('running-tools.pace-calculator-target-set')).to.equal('"A"'); + + // Assert selected race target set is loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('race'); + await wrapper.findComponent({ name: 'target-set-selector' }).setValue('C', 'selectedTargetSet'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[1].targets); + expect(localStorage.getItem('running-tools.race-calculator-target-set')).to.equal('"C"'); + + // Assert selected workout target set is loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); + await wrapper.findComponent({ name: 'target-set-selector' }).setValue('E', 'selectedTargetSet'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[2].targets); + expect(localStorage.getItem('running-tools.workout-calculator-target-set')).to.equal('"E"'); +}); + +test('should load advanced model options from localStorage', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ + model: 'PurdyPointsModel', + riegelExponent: 1.2, + })); + localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ + model: 'RiegelModel', + riegelExponent: 1.1, + })); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert race prediction options are loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('race'); + expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({ + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }); + + // Assert workout prediction options are loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); + expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({ + model: 'RiegelModel', + riegelExponent: 1.1, + }); +}); + +test('should pass correct input props to DoubleOutputTable', async () => { + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert that initial props are correct + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({ + distanceValue: 5, + distanceUnit: 'kilometers', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([ + 1200, 1215, 1230, 1245, 1260, 1275, 1290, 1305, 1320, 1335, + 1350, 1365, 1380, 1395, 1410, 1425, 1440, 1455, 1470, 1485, + ]); + + // Change input pace + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }); + + // Assert that the props are updated + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([ + 600, 615, 630, 645, 660, 675, 690, 705, 720, 735, + 750, 765, 780, 795, 810, 825, 840, 855, 870, 885, + ]); + + // Change increment value + await wrapper.findComponent({ name: 'time-input' }).setValue(10); + + // Assert that the props are updated + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([ + 600, 610, 620, 630, 640, 650, 660, 670, 680, 690, + 700, 710, 720, 730, 740, 750, 760, 770, 780, 790, + ]); + + // Change number of rows + await wrapper.findComponent({ name: 'integer-input' }).setValue(15); + + // Assert that the props are updated + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([ + 600, 610, 620, 630, 640, 650, 660, 670, 680, 690, 700, 710, 720, 730, 740, + ]); +}); + +test('should correctly calculate outputs', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ + model: 'PurdyPointsModel', + riegelExponent: 1.2, + })); + localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ + model: 'RiegelModel', + riegelExponent: 1.1, + })); + localStorage.setItem('running-tools.default-unit-system', '"imperial"'); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + const input = { distanceValue: 2, distanceUnit: 'miles', time: 600 }; + + // Assert pace outputs are calculated correctly + await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); + let calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult; + expect(calculate(input, { type: 'time', time: 3600 })).to.deep.equal({ + key: '12.00 mi', + value: '1:00:00', + pace: '5:00 / mi', + sort: 3600, + result: 'key', + }); + + // Assert race outputs are calculated correctly + await wrapper.find('select[aria-label="Calculator"]').setValue('race'); + calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult; + expect(calculate(input, { type: 'time', time: 3600 })).to.deep.equal({ + key: '10.93 mi', + value: '1:00:00', + pace: '5:29 / mi', + sort: 3600, + result: 'key', + }); + + // Assert workout outputs are calculated correctly + await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); + calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult; + const workoutTarget = { type: 'time', time: 3600, splitValue: 1, splitUnit: 'miles' }; + const result = calculate(input, workoutTarget); + expect(result.key).to.equal('1 mi @ 1:00:00'); + expect(result.value).to.equal('5:53.07'); + expect(result.pace).to.equal(''); + expect(result.sort).to.be.closeTo(353.07, 0.01); + expect(result.result).to.equal('value'); +}); diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js @@ -1,6 +1,7 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import PaceCalculator from '@/views/PaceCalculator.vue'; +import { defaultTargetSets } from '@/utils/targets'; beforeEach(() => { localStorage.clear(); @@ -11,59 +12,57 @@ test('should correctly calculate time results', async () => { const wrapper = shallowMount(PaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(1); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(100); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'kilometers', + time: 100, + }); // Calculate result - const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult; + const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; const result = calculateResult({ distanceValue: 20, distanceUnit: 'meters', - result: 'time', + type: 'distance', }); // Assert result is correct expect(result).to.deep.equal({ - distanceValue: 20, - distanceUnit: 'meters', - time: 2, - result: 'time', + key: '20 m', + value: '0:02.00', + pace: '2:41 / mi', + result: 'value', + sort: 2, }); }); test('should correctly calculate distance results according to default units setting', async () => { // Initialize component - const wrapper = shallowMount(PaceCalculator, { - data() { - return { - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(PaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(2); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 2, + distanceUnit: 'miles', + time: 1200, + }); + + // Set default units + await wrapper.find('select[aria-label="Default units"]').setValue('metric'); // Get calculate result function - const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult; + const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; // Assert result is correct - let result = calculateResult({ result: 'distance', time: 600 }); - expect(result.distanceValue).to.be.closeTo(1.609, 0.001); - expect(result.distanceUnit).to.equal('kilometers'); + let result = calculateResult({ type: 'time', time: 600 }); + expect(result.key).to.equal('1.61 km'); // Change default units await wrapper.find('select[aria-label="Default units"]').setValue('imperial'); // Assert result is correct - result = calculateResult({ result: 'distance', time: 600 }); - expect(result.distanceValue).to.equal(1); - expect(result.distanceUnit).to.equal('miles'); - expect(result.time).to.equal(600); - expect(result.result).to.equal('distance'); + result = calculateResult({ type: 'time', time: 600 }); + expect(result.key).to.equal('1.00 mi'); }); test('should not show paces in results table', async () => { @@ -71,56 +70,47 @@ test('should not show paces in results table', async () => { const wrapper = shallowMount(PaceCalculator); // Assert paces are not shown in results table - expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.showPace).to.equal(false); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.showPace).to.equal(false); }); test('should correctly handle null target set', async () => { // Initialize component - const paceTargets = [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - ]; - const wrapper = shallowMount(PaceCalculator, { - data() { - return { - targetSets: { - '_pace_targets': { - name: 'Common pace targets', - targets: paceTargets, - }, - '_race_targets': null, - }, - }; - }, - }); + const wrapper = shallowMount(PaceCalculator); // Switch to invalid target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_race_targets'); + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('does_not_exist', 'selectedTargetSet'); - // Assert empty array passed to SimpleTargetTable component - expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal([]); + // Assert empty array passed to SingleOutputTable component + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets).to.deep.equal([]); // Switch to valid target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_pace_targets'); + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('_pace_targets', 'selectedTargetSet'); - // Assert valid targets passed to SimpleTargetTable component - expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal(paceTargets); + // Assert valid targets passed to SingleOutputTable component + const paceTargets = defaultTargetSets._pace_targets.targets; + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(paceTargets); }); test('should load input pace from localStorage', async () => { // Initialize localStorage - localStorage.setItem('running-tools.pace-calculator-input-distance', '1'); - localStorage.setItem('running-tools.pace-calculator-input-unit', '"miles"'); - localStorage.setItem('running-tools.pace-calculator-input-time', '600'); + localStorage.setItem('running-tools.pace-calculator-input', JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); // Initialize component const wrapper = shallowMount(PaceCalculator); // Assert data loaded - expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1); - expect(wrapper.find('select[aria-label="Input distance unit"]').element.value).to.equal('miles'); - expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600); + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); }); test('should save input pace to localStorage', async () => { @@ -128,50 +118,51 @@ test('should save input pace to localStorage', async () => { const wrapper = shallowMount(PaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(1); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles'); - await wrapper.findComponent({ name: 'time-input' }).setValue(600); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); // Assert data saved to localStorage - expect(localStorage.getItem('running-tools.pace-calculator-input-distance')).to.equal('1'); - expect(localStorage.getItem('running-tools.pace-calculator-input-unit')).to.equal('"miles"'); - expect(localStorage.getItem('running-tools.pace-calculator-input-time')).to.equal('600'); + expect(localStorage.getItem('running-tools.pace-calculator-input')).to.equal(JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); }); test('should load selected target set from localStorage', async () => { // Initialize localStorage - localStorage.setItem('running-tools.pace-calculator-target-set', '"_race_targets"'); + const targetSet2 = { + name: 'Pace targets #2', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }; + localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify({ + '_pace_targets': { + name: 'Pace targets #1', + targets: [ + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + ], + }, + 'B': targetSet2, + })); + localStorage.setItem('running-tools.pace-calculator-target-set', '"B"'); // Initialize component - const raceTargets = [ - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, - ]; - const wrapper = shallowMount(PaceCalculator, { - data() { - return { - targetSets: { - '_pace_targets': { - name: 'Common pace targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - ], - }, - '_race_targets': { - name: 'Common race targets', - targets: raceTargets, - }, - }, - }; - }, - }); + const wrapper = shallowMount(PaceCalculator); // Assert selection is loaded - expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.modelValue).to.equal('_race_targets'); - expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal(raceTargets); + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet) + .to.equal('B'); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(targetSet2.targets); }); test('should save selected target set to localStorage when modified', async () => { @@ -179,23 +170,20 @@ test('should save selected target set to localStorage when modified', async () = const wrapper = shallowMount(PaceCalculator); // Select a new target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_race_targets'); + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('B', 'selectedTargetSet'); // New selected target set should be saved to localStorage - expect(localStorage.getItem('running-tools.pace-calculator-target-set')).to.equal('"_race_targets"'); + expect(localStorage.getItem('running-tools.pace-calculator-target-set')) + .to.equal('"B"'); }); test('should save default units setting to localStorage when modified', async () => { // Initialize component - const wrapper = shallowMount(PaceCalculator, { - data() { - return { - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(PaceCalculator); // Change default units + await wrapper.find('select[aria-label="Default units"]').setValue('metric'); await wrapper.find('select[aria-label="Default units"]').setValue('imperial'); // New default units should be saved to localStorage diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -1,6 +1,7 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import RaceCalculator from '@/views/RaceCalculator.vue'; +import { defaultTargetSets } from '@/utils/targets'; beforeEach(() => { localStorage.clear(); @@ -10,60 +11,64 @@ test('should correctly predict race times', async () => { // Initialize component const wrapper = shallowMount(RaceCalculator); - // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(5); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + // Enter input race data + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); // Calculate result - const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult; + const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; const result = calculateResult({ distanceValue: 10, distanceUnit: 'kilometers', - result: 'time', + type: 'distance', }); // Assert result is correct - expect(result.time).to.be.closeTo(2495, 1); - expect(result.distanceValue).to.equal(10); - expect(result.distanceUnit).to.equal('kilometers'); - expect(result.result).to.equal('time'); + expect(result.key).to.equal('10 km'); + expect(result.value).to.equal('41:34.80'); + expect(result.pace).to.equal('6:42 / mi'); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(2494.80, 0.01); }); test('should correctly calculate distance results according to default units setting', async () => { // Initialize component - const wrapper = shallowMount(RaceCalculator, { - data() { - return { - defaultUnitSystem: 'metric', - }; - }, + const wrapper = shallowMount(RaceCalculator); + + // Enter input race data + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, }); - // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(5); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + // Set default units + await wrapper.find('select[aria-label="Default units"]').setValue('metric'); // Get calculate result function - const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult; + const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; // Assert result is correct - let result = calculateResult({ result: 'distance', time: 2495 }); - expect(result.distanceValue).to.be.closeTo(10, 0.01); - expect(result.distanceUnit).to.equal('kilometers'); - expect(result.time).to.equal(2495); - expect(result.result).to.equal('distance'); + let result = calculateResult({ type: 'time', time: 2495 }); + expect(result.key).to.equal('10.00 km'); + expect(result.value).to.equal('41:35'); + expect(result.pace).to.equal('4:09 / km'); + expect(result.result).to.equal('key'); + expect(result.sort).to.equal(2495); // Change default units await wrapper.find('select[aria-label="Default units"]').setValue('imperial'); // Assert result is correct - result = calculateResult({ result: 'distance', time: 2495 }); - expect(result.distanceValue).to.be.closeTo(6.214, 0.01); - expect(result.distanceUnit).to.equal('miles'); - expect(result.time).to.equal(2495); - expect(result.result).to.equal('distance'); + result = calculateResult({ type: 'time', time: 2495 }); + expect(result.key).to.equal('6.21 mi'); + expect(result.value).to.equal('41:35'); + expect(result.pace).to.equal('6:41 / mi'); + expect(result.result).to.equal('key'); + expect(result.sort).to.equal(2495); }); test('should show paces in results table', async () => { @@ -71,51 +76,40 @@ test('should show paces in results table', async () => { const wrapper = shallowMount(RaceCalculator); // Assert paces are shown in results table - expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.showPace).to.equal(true); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.showPace).to.equal(true); }); test('should correctly handle null target set', async () => { // Initialize component - const raceTargets = [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - ]; - const wrapper = shallowMount(RaceCalculator, { - data() { - return { - targetSets: { - '_pace_targets': null, - '_race_targets': { - name: 'Common race targets', - targets: raceTargets, - }, - }, - }; - }, - }); + const wrapper = shallowMount(RaceCalculator); // Switch to invalid target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_pace_targets'); + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('does_not_exist', 'selectedTargetSet'); - // Assert empty array passed to SimpleTargetTable component - expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal([]); + // Assert empty array passed to SingleOutputTable component + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets).to.deep.equal([]); // Switch to valid target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_race_targets'); + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('_race_targets', 'selectedTargetSet'); - // Assert valid targets passed to SimpleTargetTable component - expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal(raceTargets); + // Assert valid targets passed to SingleOutputTable component + const raceTargets = defaultTargetSets._race_targets.targets; + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(raceTargets); }); test('should correctly calculate race statistics', async () => { // Initialize component const wrapper = shallowMount(RaceCalculator); - // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(5); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + // Enter input race data + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); // Get race statistics const raceStats = wrapper.findAll('details')[0]; @@ -133,104 +127,116 @@ test('should correctly calculate results according to advanced model options', a // Initialize component const wrapper = shallowMount(RaceCalculator); - // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(5); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + // Enter input race data + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); // Switch model - await wrapper.find('select[aria-label="Prediction model"]').setValue('RiegelModel'); + await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + model: 'RiegelModel', + riegelExponent: 1.06, // default value + }); // Calculate result - const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult; + const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; let result = calculateResult({ distanceValue: 10, distanceUnit: 'kilometers', - result: 'time', + type: 'distance', }); // Assert result is correct - expect(result.time).to.be.closeTo(2502, 1); + expect(result.value).to.equal('41:41.92'); // Update Riegel Exponent - expect(wrapper.findComponent('[aria-label="Riegel exponent"').vm.modelValue).to.equal(1.06); - await wrapper.findComponent('[aria-label="Riegel exponent"').setValue(1); + await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + model: 'RiegelModel', // existing value + riegelExponent: 1, + }); // Calculate result result = calculateResult({ distanceValue: 10, distanceUnit: 'kilometers', - result: 'time', + type: 'distance', }); // Assert result is correct - expect(result.time).to.equal(2400); + expect(result.value).to.equal('40:00.00'); }); -test('should load input pace from localStorage', async () => { +test('should load input race from localStorage', async () => { // Initialize localStorage - localStorage.setItem('running-tools.race-calculator-input-distance', '1'); - localStorage.setItem('running-tools.race-calculator-input-unit', '"miles"'); - localStorage.setItem('running-tools.race-calculator-input-time', '600'); + localStorage.setItem('running-tools.race-calculator-input', JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); // Initialize component const wrapper = shallowMount(RaceCalculator); // Assert data loaded - expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1); - expect(wrapper.find('select[aria-label="Input distance unit"]').element.value).to.equal('miles'); - expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600); + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); }); -test('should save input pace to localStorage', async () => { +test('should save input race to localStorage', async () => { // Initialize component const wrapper = shallowMount(RaceCalculator); - // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(1); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles'); - await wrapper.findComponent({ name: 'time-input' }).setValue(600); + // Enter input race data + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); // Assert data saved to localStorage - expect(localStorage.getItem('running-tools.race-calculator-input-distance')).to.equal('1'); - expect(localStorage.getItem('running-tools.race-calculator-input-unit')).to.equal('"miles"'); - expect(localStorage.getItem('running-tools.race-calculator-input-time')).to.equal('600'); + expect(localStorage.getItem('running-tools.race-calculator-input')).to.equal(JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); }); test('should load selected target set from localStorage', async () => { // Initialize localStorage - localStorage.setItem('running-tools.race-calculator-target-set', '"_pace_targets"'); + const targetSet2 = { + name: 'Race targets #2', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }; + localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({ + '_race_targets': { + name: 'Race targets #1', + targets: [ + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + ], + }, + 'B': targetSet2, + })); + localStorage.setItem('running-tools.race-calculator-target-set', '"B"'); // Initialize component - const paceTargets = [ - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 3, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 6, distanceUnit: 'kilometers' }, - ]; - const wrapper = shallowMount(RaceCalculator, { - data() { - return { - targetSets: { - '_pace_targets': { - name: 'Common pace targets', - targets: paceTargets, - }, - '_race_targets': { - name: 'Common race targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - ], - }, - }, - }; - }, - }); + const wrapper = shallowMount(RaceCalculator); // Assert selection is loaded - expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.modelValue).to.equal('_pace_targets'); - expect(wrapper.findComponent({ name: 'simple-target-table' }).vm.targets).to.deep.equal(paceTargets); + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet) + .to.equal('B'); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(targetSet2.targets); }); test('should save selected target set to localStorage when modified', async () => { @@ -238,23 +244,20 @@ test('should save selected target set to localStorage when modified', async () = const wrapper = shallowMount(RaceCalculator); // Select a new target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_pace_targets'); + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('B', 'selectedTargetSet'); // New selected target set should be saved to localStorage - expect(localStorage.getItem('running-tools.race-calculator-target-set')).to.equal('"_pace_targets"'); + expect(localStorage.getItem('running-tools.race-calculator-target-set')) + .to.equal('"B"'); }); test('should save default units setting to localStorage when modified', async () => { // Initialize component - const wrapper = shallowMount(RaceCalculator, { - data() { - return { - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(RaceCalculator); // Change default units + await wrapper.find('select[aria-label="Default units"]').setValue('metric'); await wrapper.find('select[aria-label="Default units"]').setValue('imperial'); // New default units should be saved to localStorage @@ -263,15 +266,19 @@ test('should save default units setting to localStorage when modified', async () test('should load advanced model options from localStorage', async () => { // Initialize localStorage - localStorage.setItem('running-tools.race-calculator-model', '"PurdyPointsModel"'); - localStorage.setItem('running-tools.race-calculator-riegel-exponent', '1.20'); + localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ + model: 'PurdyPointsModel', + riegelExponent: 1.2, + })); // Initialize component const wrapper = shallowMount(RaceCalculator); // Assert data loaded - expect(wrapper.find('select[aria-label="Prediction model"]').element.value).to.equal('PurdyPointsModel'); - expect(wrapper.findComponent('[aria-label="Riegel exponent"]').vm.modelValue).to.equal(1.20); + expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({ + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }); }); test('should save advanced model options to localStorage when modified', async () => { @@ -279,11 +286,15 @@ test('should save advanced model options to localStorage when modified', async ( const wrapper = shallowMount(RaceCalculator); // Update advanced model options - await wrapper.find('select[aria-label="Prediction model"]').setValue('CameronModel'); - await wrapper.findComponent('[aria-label="Riegel exponent"]').setValue(1.30); + await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + model: 'CameronModel', + riegelExponent: 1.30, + }); // Assert data saved to localStorage - expect(localStorage.getItem('running-tools.race-calculator-model')).to.equal('"CameronModel"'); - expect(localStorage.getItem('running-tools.race-calculator-riegel-exponent')).to.equal('1.3'); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(JSON.stringify({ + model: 'CameronModel', + riegelExponent: 1.3, + })); }); diff --git a/tests/unit/views/SplitCalculator.spec.js b/tests/unit/views/SplitCalculator.spec.js @@ -6,502 +6,180 @@ beforeEach(() => { localStorage.clear(); }) -test('should initialize undefined splits to 0:00.00', async () => { +test('should load selected target set from localStorage', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.split-calculator-target-set', '"B"'); + // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters' }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); - - // Assert results are correct - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[0].findAll('td').length).to.equal(4); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[1].findAll('td').length).to.equal(4); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[2].findAll('td').length).to.equal(4); - expect(rows.length).to.equal(3); + const wrapper = shallowMount(SplitCalculator); + + // Assert selection is loaded + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('B'); }); -test('should correctly load split times from split targets', async () => { - // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; +test('should load targets from localStorage and pass to splitOutputTable', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.split-calculator-target-sets', JSON.stringify({ + '_split_targets': { + name: 'Split targets', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], }, - }); - - // Assert results are correct - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00'); - expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km'); - expect(rows[0].findAll('td').length).to.equal(4); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00'); - expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km'); - expect(rows[1].findAll('td').length).to.equal(4); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00'); - expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km'); - expect(rows[2].findAll('td').length).to.equal(4); - expect(rows.length).to.equal(3); -}); + 'B': { + name: 'Split targets #2', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, + ], + }, + })); -test('should correctly handle null target set', async () => { // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, - ], - }, - 'B': null, - }, - selectedTargetSet: 'B', - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(SplitCalculator); - // Assert results are empty - let rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent.trim()).to.equal('There aren\'t any targets in this set yet.'); - expect(rows[0].findAll('td').length).to.equal(1); - expect(rows.length).to.equal(1); + // Assert default split targets are initially loaded + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet) + .to.equal('_split_targets'); + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ]); - // Switch to valid target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('_split_targets'); - - // Assert results are correct - rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00'); - expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km'); - expect(rows[0].findAll('td').length).to.equal(4); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00'); - expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km'); - expect(rows[1].findAll('td').length).to.equal(4); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00'); - expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km'); - expect(rows[2].findAll('td').length).to.equal(4); - expect(rows.length).to.equal(3); + // Select a new target set + await wrapper.findComponent({ name: 'target-set-selector' }).setValue('B', 'selectedTargetSet'); + + // Assert new target set is loaded + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet) + .to.equal('B'); + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([ + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, + ]); }); -test('should correctly calculate paces and cululative times from entered split times', async () => { +test('should correctly handle null target set', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.split-calculator-target-set', '"does_not_exist"'); + // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 180 }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(SplitCalculator); - // Update split times - await wrapper.findAllComponents({ name: 'time-input' })[1].setValue(190); - await wrapper.findAllComponents({ name: 'time-input' })[2].setValue(200); - - // Assert results are correct - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00'); - expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km'); - expect(rows[0].findAll('td').length).to.equal(4); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00'); - expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km'); - expect(rows[1].findAll('td').length).to.equal(4); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00'); - expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km'); - expect(rows[2].findAll('td').length).to.equal(4); - expect(rows.length).to.equal(3); -}); + // Assert selection is loaded + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet).to.equal('does_not_exist'); -test('should correctly sort split targets', async () => { - // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); - - // Assert results are correct - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[0].findAll('td').length).to.equal(4); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[1].findAll('td').length).to.equal(4); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('2 mi'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[2].findAll('td').length).to.equal(4); - expect(rows.length).to.equal(3); + // Assert empty array passed to SplitOutputTable + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([]); + + // Switch to valid target set + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('_split_targets', 'selectedTargetSet'); + + // Assert non-empty target set passed to SplitOutputTable + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ]); }); -test('should ignore time based targets', async () => { - // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers' }, - { result: 'distance', time: 600 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers' }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters' }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; +test('should update targets in localStorage when modified by splitOutputTable', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.split-calculator-target-sets', JSON.stringify({ + '_split_targets': { + name: 'Split targets', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 180 }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 180 }, + ], }, - }); - - // Assert results are correct - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[0].findAll('td').length).to.equal(4); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[1].findAll('td').length).to.equal(4); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('0:00.00'); - expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(0); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('0:00 / km'); - expect(rows[2].findAll('td').length).to.equal(4); - expect(rows.length).to.equal(3); -}); + })); -test('should correctly save split times with split targets in localStorage', async () => { // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 180 }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(SplitCalculator); // Update split times - await wrapper.findAllComponents({ name: 'time-input' })[1].setValue(190); - await wrapper.findAllComponents({ name: 'time-input' })[2].setValue(200); + await wrapper.findComponent({ name: 'split-output-table' }).setValue([ + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, + ]); // Assert targets correctly saved in localStorage - expect(localStorage.getItem('running-tools.target-sets')).to.equal(JSON.stringify({ + expect(localStorage.getItem('running-tools.split-calculator-target-sets')).to.equal(JSON.stringify({ '_split_targets': { name: 'Split targets', targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, ], }, })); }); -test('should update results when a new target set is selected', async () => { - // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - ], - }, - 'B': { - name: 'Split targets #2', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); - - // Assert default split targets are initially loaded - expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.modelValue).to.equal('_split_targets'); - expect(wrapper.findAll('tbody td')[0].element.textContent).to.equal('1 mi'); - - // Select a new target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('B'); - - // Assert results are correct - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00'); - expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km'); - expect(rows[0].findAll('td').length).to.equal(4); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00'); - expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km'); - expect(rows[1].findAll('td').length).to.equal(4); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00'); - expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km'); - expect(rows[2].findAll('td').length).to.equal(4); - expect(rows.length).to.equal(3); -}); - -test('should load selected target set from localStorage', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.split-calculator-target-set', '"B"'); - - // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - ], - }, - 'B': { - name: 'Split targets #2', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); - - // Assert selection is loaded - expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.modelValue).to.equal('B'); - - // Assert results are correct - const rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[0].element.textContent).to.equal('1 km'); - expect(rows[0].findAll('td')[1].element.textContent).to.equal('3:00.00'); - expect(rows[0].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(180); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:00 / km'); - expect(rows[0].findAll('td').length).to.equal(4); - expect(rows[1].findAll('td')[0].element.textContent).to.equal('2 km'); - expect(rows[1].findAll('td')[1].element.textContent).to.equal('6:10.00'); - expect(rows[1].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(190); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:10 / km'); - expect(rows[1].findAll('td').length).to.equal(4); - expect(rows[2].findAll('td')[0].element.textContent).to.equal('3000 m'); - expect(rows[2].findAll('td')[1].element.textContent).to.equal('9:30.00'); - expect(rows[2].findAll('td')[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(200); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:20 / km'); - expect(rows[2].findAll('td').length).to.equal(4); - expect(rows.length).to.equal(3); -}); - test('should save selected target set to localStorage when modified', async () => { // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles' }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers' }, - ], - }, - 'B': { - name: 'Split targets #2', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { result: 'time', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { result: 'time', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(SplitCalculator); // Select a new target set - await wrapper.findComponent({ name: 'target-set-selector' }).setValue('B'); + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('_race_targets', 'selectedTargetSet'); // New selected target set should be saved to localStorage - expect(localStorage.getItem('running-tools.split-calculator-target-set')).to.equal('"B"'); + expect(localStorage.getItem('running-tools.split-calculator-target-set')) + .to.equal('"_race_targets"'); }); -test('should update paces according to default units setting', async () => { +test('should load default units from localStorage and pass to splitOutputTable', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.default-unit-system', '"metric"'); + // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles', split: 300 }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles', split: 300 }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers', split: 330 }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(SplitCalculator); + + // Assert default units setting is initialy loaded + expect(wrapper.find('select', { name: 'Default units' }).element.value).to.equal('metric'); - // Assert paces are correct - let rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('3:06 / km'); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('3:06 / km'); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('3:05 / km'); + // Assert prop is correct + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.defaultUnitSystem) + .to.equal('metric'); // Change default units await wrapper.find('select').setValue('imperial'); - // Assert paces are correct - rows = wrapper.findAll('tbody tr'); - expect(rows[0].findAll('td')[3].element.textContent).to.equal('5:00 / mi'); - expect(rows[1].findAll('td')[3].element.textContent).to.equal('5:00 / mi'); - expect(rows[2].findAll('td')[3].element.textContent).to.equal('4:58 / mi'); + // Assert prop is correct + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.defaultUnitSystem) + .to.equal('imperial'); }); test('should save default units setting to localStorage when modified', async () => { // Initialize component - const wrapper = shallowMount(SplitCalculator, { - data() { - return { - targetSets: { - '_split_targets': { - name: 'Split targets', - targets: [ - { result: 'time', distanceValue: 1, distanceUnit: 'miles', split: 300 }, - { result: 'time', distanceValue: 2, distanceUnit: 'miles', split: 300 }, - { result: 'time', distanceValue: 5, distanceUnit: 'kilometers', split: 330 }, - ], - }, - }, - defaultUnitSystem: 'metric', - }; - }, - }); + const wrapper = shallowMount(SplitCalculator); - // Change default units + // Set default units setting + await wrapper.find('select').setValue('metric'); + + // New default units should be saved to localStorage + expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"metric"'); + + // Set default units setting await wrapper.find('select').setValue('imperial'); // New default units should be saved to localStorage expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"'); }); + +test('should correctly set targetSetSelector setType prop', async () => { + // Initialize component + const wrapper = shallowMount(SplitCalculator); + + // Assert setType prop is correctly set + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.setType).to.equal('split'); +}); diff --git a/tests/unit/views/UnitCalculator.spec.js b/tests/unit/views/UnitCalculator.spec.js @@ -27,7 +27,7 @@ test('should correctly update controls when category changes', async () => { expect(wrapper.find('select[aria-label="Output units"]').element.value).to.equal('miles_per_hour'); // Change category - await wrapper.setData({ category: 'distance' }); + await wrapper.find('select[aria-label="Selected unit category"]').setValue('distance'); // Assert controls are correct expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1); @@ -91,15 +91,23 @@ test('should correctly convert to and from hh:mm:ss', async () => { test('should correctly load saved inputs', async () => { // Initialize localStorage - localStorage.setItem('running-tools.unit-calculator-distance-input-value', '5'); - localStorage.setItem('running-tools.unit-calculator-distance-input-unit', '"kilometers"'); - localStorage.setItem('running-tools.unit-calculator-distance-output-unit', '"miles"'); - localStorage.setItem('running-tools.unit-calculator-time-input-value', '90'); - localStorage.setItem('running-tools.unit-calculator-time-input-unit', '"hh:mm:ss"'); - localStorage.setItem('running-tools.unit-calculator-time-output-unit', '"minutes"'); - localStorage.setItem('running-tools.unit-calculator-speed-input-value', '15'); - localStorage.setItem('running-tools.unit-calculator-speed-input-unit', '"miles_per_hour"'); - localStorage.setItem('running-tools.unit-calculator-speed-output-unit', '"seconds_per_mile"'); + localStorage.setItem('running-tools.unit-calculator-inputs', JSON.stringify({ + distance: { + inputValue: 5, + inputUnit: 'kilometers', + outputUnit: 'miles', + }, + time: { + inputValue: 90, + inputUnit: 'hh:mm:ss', + outputUnit: 'minutes', + }, + speed_and_pace: { + inputValue: 15, + inputUnit: 'miles_per_hour', + outputUnit: 'seconds_per_mile', + }, + })); // Initialize component const wrapper = shallowMount(UnitCalculator); @@ -121,7 +129,7 @@ test('should correctly load saved inputs', async () => { expect(wrapper.find('select[aria-label="Output units"]').element.value).to.equal('seconds_per_mile'); // Change category - await wrapper.setData({ category: 'distance' }); + await wrapper.find('select[aria-label="Selected unit category"]').setValue('distance'); // Assert inputs are correct expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(5); @@ -152,13 +160,21 @@ test('should correctly save inputs', async () => { await wrapper.find('select[aria-label="Output units"]').setValue('seconds_per_mile'); // Initialize localStorage - expect(localStorage.getItem('running-tools.unit-calculator-distance-input-value')).to.equal('5'); - expect(localStorage.getItem('running-tools.unit-calculator-distance-input-unit')).to.equal('"kilometers"'); - expect(localStorage.getItem('running-tools.unit-calculator-distance-output-unit')).to.equal('"miles"'); - expect(localStorage.getItem('running-tools.unit-calculator-time-input-value')).to.equal('90'); - expect(localStorage.getItem('running-tools.unit-calculator-time-input-unit')).to.equal('"hh:mm:ss"'); - expect(localStorage.getItem('running-tools.unit-calculator-time-output-unit')).to.equal('"minutes"'); - expect(localStorage.getItem('running-tools.unit-calculator-speed-input-value')).to.equal('15'); - expect(localStorage.getItem('running-tools.unit-calculator-speed-input-unit')).to.equal('"miles_per_hour"'); - expect(localStorage.getItem('running-tools.unit-calculator-speed-output-unit')).to.equal('"seconds_per_mile"'); + expect(localStorage.getItem('running-tools.unit-calculator-inputs')).to.equal(JSON.stringify({ + distance: { + inputValue: 5, + inputUnit: 'kilometers', + outputUnit: 'miles', + }, + time: { + inputValue: 90, + inputUnit: 'hh:mm:ss', + outputUnit: 'minutes', + }, + speed_and_pace: { + inputValue: 15, + inputUnit: 'miles_per_hour', + outputUnit: 'seconds_per_mile', + }, + })); }); diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js @@ -0,0 +1,238 @@ +import { beforeEach, test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import WorkoutCalculator from '@/views/WorkoutCalculator.vue'; +import { defaultTargetSets } from '@/utils/targets'; + +beforeEach(() => { + localStorage.clear(); +}) + +test('should correctly predict workout splits', async () => { + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Enter input race data + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); + + // Calculate result + const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; + const result = calculateResult({ + splitValue: 1, splitUnit: 'kilometers', + type: 'distance', distanceValue: 10, distanceUnit: 'kilometers', + }); + + // Assert result is correct + expect(result.key).to.equal('1 km @ 10 km'); + expect(result.value).to.equal('4:09.48'); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(249.48, 0.01); +}); + +test('should correctly handle null target set', async () => { + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Switch to invalid target set + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('does_not_exist', 'selectedTargetSet'); + + // Assert empty array passed to SingleOutputTable component + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets).to.deep.equal([]); + + // Switch to valid target set + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('_workout_targets', 'selectedTargetSet'); + + // Assert valid targets passed to SingleOutputTable component + const workoutTargets = defaultTargetSets._workout_targets.targets; + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(workoutTargets); +}); + +test('should correctly calculate results according to advanced model options', async () => { + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Enter input race data + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); + + // Update model and Riegel Exponent + await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + model: 'RiegelModel', + riegelExponent: 1.10, + }); + + // Calculate result + const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; + let result = calculateResult({ + splitValue: 1, splitUnit: 'kilometers', + type: 'distance', distanceValue: 10, distanceUnit: 'kilometers', + }); + + // Assert result is correct + expect(result.key).to.equal('1 km @ 10 km'); + expect(result.value).to.equal('4:17.23'); +}); + +test('should load input race from localStorage', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.workout-calculator-input', JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); + + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Assert data loaded + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); +}); + +test('should save input race to localStorage', async () => { + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Enter input race data + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); + + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.workout-calculator-input')).to.equal(JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); +}); + +test('should load selected target set from localStorage', async () => { + // Initialize localStorage + const targetSet2 = { + name: 'Workout targets #2', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + distanceUnit: 'kilometers', distanceValue: 5, + splitUnit: 'miles', splitValue: 1, + type: 'distance' + }, + ], + }; + localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({ + '_workout_targets': { + name: 'Workout targets #1', + targets: [ + { + splitValue: 400, splitUnit: 'meters', + type: 'distance', distanceValue: 1, distanceUnit: 'miles', + }, + { + splitValue: 800, splitUnit: 'meters', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', + }, + { + splitValue: 1600, splitUnit: 'meters', + type: 'time', time: 3600, + }, + { + splitValue: 2, splitUnit: 'miles', + type: 'time', time: 7200, + }, + ], + }, + 'B': targetSet2, + })); + localStorage.setItem('running-tools.workout-calculator-target-set', '"B"'); + + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Assert selection is loaded + expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet) + .to.equal('B'); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(targetSet2.targets); +}); + +test('should save selected target set to localStorage when modified', async () => { + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Select a new target set + await wrapper.findComponent({ name: 'target-set-selector' }) + .setValue('B', 'selectedTargetSet'); + + // New selected target set should be saved to localStorage + expect(localStorage.getItem('running-tools.workout-calculator-target-set')) + .to.equal('"B"'); +}); + +test('should save default units setting to localStorage when modified', async () => { + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Change default units + await wrapper.find('select[aria-label="Default units"]').setValue('metric'); + await wrapper.find('select[aria-label="Default units"]').setValue('imperial'); + + // New default units should be saved to localStorage + expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"'); +}); + +test('should load advanced model options from localStorage', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ + model: 'PurdyPointsModel', + riegelExponent: 1.2, + })); + + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Assert data loaded + expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({ + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }); +}); + +test('should save advanced model options to localStorage when modified', async () => { + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Update advanced model options + await wrapper.findComponent({ name: 'RaceOptions' }).setValue({ + model: 'CameronModel', + riegelExponent: 1.30, + }); + + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({ + model: 'CameronModel', + riegelExponent: 1.3, + })); +}); 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)'], }, });