running-tools

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

commit 4306a1e6fa2d6248fc4a4c0b8020d1aff715664c
parent 312357263be01c8448b601e39dbeb5badbcc0a81
Author: ashermorgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Mon, 30 Aug 2021 13:23:24 -0700

Merge branch 'dev'

Version 1.0.0

Diffstat:
ACHANGELOG.md | 28++++++++++++++++++++++++++++
MREADME.md | 9+++++++--
Mpackage-lock.json | 4++--
Mpackage.json | 4++--
Apublic/404.html | 44++++++++++++++++++++++++++++++++++++++++++++
Apublic/img/icons/open-graph-1280x640.png | 0
Mpublic/index.html | 33+++++++++++++++++++++++++++++++--
Ascripts/deploy.sh | 17+++++++++++++++++
Msrc/App.vue | 16++++++++++++++--
Asrc/assets/edit.svg | 1+
Msrc/assets/global.css | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Asrc/assets/plus-circle.svg | 1+
Asrc/assets/rotate-ccw.svg | 1+
Asrc/assets/trash-2.svg | 1+
Asrc/assets/x.svg | 1+
Msrc/components/DecimalInput.vue | 28++++++++++++++++++++++++++--
Msrc/components/IntInput.vue | 28++++++++++++++++++++++++++--
Msrc/components/TimeInput.vue | 8++++----
Asrc/components/TimeTable.vue | 292+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/directives/blur.js | 5+++++
Msrc/router/index.js | 10++++++++++
Asrc/utils/localStorage.js | 38++++++++++++++++++++++++++++++++++++++
Asrc/utils/races.js | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/views/Home.vue | 47++++++++++++++++++++++++++++++++++++-----------
Msrc/views/PaceCalculator.vue | 113+++++++++++++++++++++++++------------------------------------------------------
Asrc/views/RaceCalculator.vue | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dtests/unit/DecimalInput.spec.js | 260-------------------------------------------------------------------------------
Dtests/unit/IntInput.spec.js | 238-------------------------------------------------------------------------------
Dtests/unit/PaceCalculator.spec.js | 67-------------------------------------------------------------------
Dtests/unit/TimeInput.spec.js | 59-----------------------------------------------------------
Dtests/unit/UnitCalculator.spec.js | 100-------------------------------------------------------------------------------
Atests/unit/components/DecimalInput.spec.js | 342+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/IntInput.spec.js | 320+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/TimeInput.spec.js | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/TimeTable.spec.js | 34++++++++++++++++++++++++++++++++++
Dtests/unit/pace.spec.js | 22----------------------
Atests/unit/utils/paces.spec.js | 22++++++++++++++++++++++
Atests/unit/utils/races.spec.js | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rtests/unit/units.spec.js -> tests/unit/utils/units.spec.js | 0
Atests/unit/views/PaceCalculator.spec.js | 32++++++++++++++++++++++++++++++++
Atests/unit/views/RaceCalculator.spec.js | 34++++++++++++++++++++++++++++++++++
Atests/unit/views/UnitCalculator.spec.js | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mvue.config.js | 2++
43 files changed, 2036 insertions(+), 856 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +## [1.0.0] - 2021-08-30 + +### Added +- Dark mode +- Race calculator + +### Changed +- The list of distance targets can be edited +- Improved appearance +- Inactive pages are cached +- Minute and second input fields wrap around + +## [0.2.0] - 2021-08-18 + +### Added +- Pace and Unit Calculators +- Progressive Web App + +## [0.1.0] - 2021-07-29 + +### Added +- Basic app structure + +[1.0.0]: https://github.com/ashermorgan/running-tools/compare/0.2.0...1.0.0 +[0.2.0]: https://github.com/ashermorgan/running-tools/compare/0.1.0...0.2.0 +[0.1.0]: https://github.com/ashermorgan/running-tools/releases/tag/0.1.0 diff --git a/README.md b/README.md @@ -1,7 +1,12 @@ # running-tools -A collection of tools for runners that calculate splits, convert units, and more +A collection of tools for runners and their coaches. Try it out [here](https://ashermorgan.github.io/running-tools/). -Try it out [here](https://ashermorgan.github.io/running-tools/) + + +## Features +- [Pace Calculator](https://ashermorgan.github.io/running-tools/#/calculate/paces): Calculate times for different distances at the same pace +- [Race Calculator](https://ashermorgan.github.io/running-tools/#/calculate/races): Estimate equivalent times for races of different distances +- [Unit Calculator](https://ashermorgan.github.io/running-tools/#/calculate/units): Convert between different distance, time, speed, and pace units diff --git a/package-lock.json b/package-lock.json @@ -1,11 +1,11 @@ { "name": "running-tools", - "version": "0.2.0", + "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "version": "0.2.0", + "version": "1.0.0", "dependencies": { "core-js": "^3.6.5", "register-service-worker": "^1.7.1", diff --git a/package.json b/package.json @@ -1,7 +1,7 @@ { "name": "running-tools", - "version": "0.2.0", - "description": "A collection of tools for runners that calculate splits, convert units, and more", + "version": "1.0.0", + "description": "A collection of tools for runners and their coaches that calculate splits, predict race times, convert units, and more", "private": true, "scripts": { "serve": "vue-cli-service serve", diff --git a/public/404.html b/public/404.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="description" content="A collection of tools for runners and their coaches that calculate splits, predict race times, convert units, and more"> + <meta name="viewport" content="width=device-width,initial-scale=1.0"> + <title>404 Not Found - Running Tools</title> + + <link rel="icon" type="image/png" sizes="32x32" href="https://ashermorgan.github.io/running-tools/img/icons/favicon-32x32.png"> + <link rel="icon" type="image/png" sizes="16x16" href="https://ashermorgan.github.io/running-tools/img/icons/favicon-16x16.png"> + <link rel="apple-touch-icon" href="https://ashermorgan.github.io/running-tools/img/icons/apple-touch-icon-180x180.png"> + + <style> + * { + margin: 0px; + padding: 0px; + box-sizing: border-box; + } + body { + font-family: Segoe UI, sans-serif; + text-align: center; + } + header { + background-color: hsl(30, 100%, 50%); + padding: 0.25em; + font-size: 2em; + font-weight: bold; + } + h1 { + font-size: 1.5em; + margin: 10px 10px 0px; + } + </style> + </head> + + <body> + <header>Running Tools</header> + <main> + <h1>404 Not Found</h1> + <p><a href="https://ashermorgan.github.io/running-tools">homepage</a></p> + </main> + </body> +</html> diff --git a/public/img/icons/open-graph-1280x640.png b/public/img/icons/open-graph-1280x640.png Binary files differ. diff --git a/public/index.html b/public/index.html @@ -3,10 +3,17 @@ <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> - <meta name="description" content="A collection of tools for runners that calculate splits, convert units, and more"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> + + <meta name="description" content="A collection of tools for runners and their coaches that calculate splits, predict race times, convert units, and more"> <title>Running Tools</title> + <meta property="og:title" content="Running Tools"/> + <meta property="og:description" content="A collection of tools for runners and their coaches that calculate splits, predict race times, convert units, and more"/> + <meta property="og:type" content="website"/> + <meta property="og:url" content="https://ashermorgan.github.io/running-tools/"/> + <meta property="og:image" content="https://ashermorgan.github.io/running-tools/img/icons/open-graph-1280x640.png"/> + <link rel="apple-touch-startup-image" href="img/icons/apple-splash-2048x2732.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="img/icons/apple-splash-2732x2048.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="img/icons/apple-splash-1668x2388.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"> @@ -36,7 +43,29 @@ </head> <body> <noscript> - <strong>We're sorry but Running Tools doesn't work properly without JavaScript enabled. Please enable it to continue.</strong> + <style> + * { + margin: 0px; + padding: 0px; + box-sizing: border-box; + } + body { + font-family: Segoe UI, sans-serif; + text-align: center; + } + header { + background-color: hsl(30, 100%, 50%); + padding: 0.25em; + font-size: 2em; + font-weight: bold; + } + p { + margin: 10px; + 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> </noscript> <div id="app"></div> <!-- built files will be auto injected --> diff --git a/scripts/deploy.sh b/scripts/deploy.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env sh + +# https://cli.vuejs.org/guide/deployment.html#github-pages + +# abort on errors +set -e + +npm run build + +cd dist + +git init +git add -A +git commit -m 'deploy' +git push -f git@github.com:ashermorgan/running-tools.git master:gh-pages + +cd - diff --git a/src/App.vue b/src/App.vue @@ -15,14 +15,16 @@ </header> <div id="route-content"> - <router-view/> + <keep-alive> + <router-view/> + </keep-alive> </div> </div> </template> <style scoped> header { - background-color: hsl(30, 100%, 50%); + background-color: var(--theme); padding: 0.5em; display: grid; grid-template-columns: 2em 1fr auto 1fr 2em; @@ -34,6 +36,10 @@ header a { height: 2em; width: 2em; } +header a img { + padding: 0em; + filter: invert(0%) !important; +} h1 { grid-column: 3; font-size: 2em; @@ -44,4 +50,10 @@ h1 { #route-content { margin: 1em; } +@media only screen and (max-width: 320px) { + /* adjust title size to fit small devices */ + h1 { + font-size: 8vw; + } +} </style> diff --git a/src/assets/edit.svg b/src/assets/edit.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg> diff --git a/src/assets/global.css b/src/assets/global.css @@ -6,14 +6,20 @@ } body { font-family: Segoe UI, sans-serif; + touch-action: manipulation; } -input, select { - padding: 0.2em; +input, select, button { + padding: 0.3em 0.5em; + border-radius: 0.4em; +} +button { + cursor: pointer; } /* styles for icons */ .icon { border: none; + padding: 0em; background-color: #00000000; cursor: pointer; vertical-align: middle; @@ -21,11 +27,88 @@ input, select { .icon img { width: 100%; height: 100%; + padding: 0.3em; } -/* default styles for mobile devices */ +/* styles for mobile devices */ @media only screen and (max-width: 800px) { - input, select { + input, select, button { font-size: 1em; } } + +/* element colors */ +body, input, select, button, option { + color: var(--foreground); +} +body { + background-color: var(--background1); +} +button, input, select, option, tr:nth-child(2n) { + background-color: var(--background2); +} +button:focus, select:focus, input:focus { + background-color: var(--background3); +} +@media (hover: hover) { + button:hover, select:hover, input:hover { + background-color: var(--background3); + } +} +button:active { + background-color: var(--background4); +} +button, input, select, tr { + border: 1px solid var(--background5); +} + +/* light/default theme */ +:root { + /* The theme color of the app */ + --theme: hsl(30, 100%, 50%); + + /* The background color of the app */ + --background1: #ffffff; + + /* The default background color of app elements */ + --background2: #f8f8f8; + + /* The background color of focused app elements */ + --background3: #f0f0f0; + + /* The background color of active app elements */ + --background4: #e8e8e8; + + /* The border color of app elements */ + --background5: #e0e0e0; + + /* The foreground color of app elements */ + --foreground: #000000; +} + +/* dark mode */ +@media only screen and (prefers-color-scheme: dark) { + :root { + --background1: hsl(210, 20%, 10%); + --background2: hsl(210, 20%, 15%); + --background3: hsl(210, 20%, 20%); + --background4: hsl(210, 20%, 25%); + --background5: hsl(210, 20%, 30%); + --foreground: #e8e8e8; + } + .icon img { + filter: invert(90%); + } +} + +/* print media mode */ +@media only print { + :root { + --background1: #ffffff; + --background2: #ffffff; + --background3: #ffffff; + --background4: #ffffff; + --background5: #000000; + --foreground: #000000; + } +} diff --git a/src/assets/plus-circle.svg b/src/assets/plus-circle.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-plus-circle"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg> diff --git a/src/assets/rotate-ccw.svg b/src/assets/rotate-ccw.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-rotate-ccw"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg> diff --git a/src/assets/trash-2.svg b/src/assets/trash-2.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-trash-2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg> diff --git a/src/assets/x.svg b/src/assets/x.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> diff --git a/src/components/DecimalInput.vue b/src/components/DecimalInput.vue @@ -37,6 +37,22 @@ export default { }, /** + * The step value + */ + step: { + type: Number, + default: 1, + }, + + /** + * Whether to wrap around at the minimum and maximum values + */ + wrap: { + type: Boolean, + default: false, + }, + + /** * The number of digits to show before the decimal point */ padding: { @@ -168,10 +184,18 @@ export default { */ onkeydown(e) { if (e.key === 'ArrowUp') { - this.decValue += 1; + if (this.decValue === this.max && this.wrap && this.min !== null) { + this.decValue = this.min; + } else { + this.decValue += this.step; + } e.preventDefault(); } else if (e.key === 'ArrowDown') { - this.decValue -= 1; + if (this.decValue === this.min && this.wrap && this.max !== null) { + this.decValue = this.max; + } else { + this.decValue -= this.step; + } e.preventDefault(); } }, diff --git a/src/components/IntInput.vue b/src/components/IntInput.vue @@ -37,6 +37,22 @@ export default { }, /** + * The step value + */ + step: { + type: Number, + default: 1, + }, + + /** + * Whether to wrap around at the minimum and maximum values + */ + wrap: { + type: Boolean, + default: false, + }, + + /** * The number of digits to show before the decimal point */ padding: { @@ -157,10 +173,18 @@ export default { */ onkeydown(e) { if (e.key === 'ArrowUp') { - this.intValue += 1; + if (this.intValue === this.max && this.wrap && this.min !== null) { + this.intValue = this.min; + } else { + this.intValue += this.step; + } e.preventDefault(); } else if (e.key === 'ArrowDown') { - this.intValue -= 1; + if (this.intValue === this.min && this.wrap && this.max !== null) { + this.intValue = this.max; + } else { + this.intValue -= this.step; + } e.preventDefault(); } }, diff --git a/src/components/TimeInput.vue b/src/components/TimeInput.vue @@ -1,13 +1,13 @@ <template> <div class="time-input"> <int-input class="hours" aria-label="hours" - :min="0" :max="23" :padding="1" v-model="hours"/> + :min="0" :max="99" :padding="1" v-model="hours"/> <span>:</span> <int-input class="minutes" aria-label="minutes" - :min="0" :max="59" :padding="2" v-model="minutes"/> + :min="0" :max="59" wrap :padding="2" v-model="minutes"/> <span>:</span> <decimal-input class="seconds" aria-label="seconds" - :min="0" :max="59.99" :padding="2" :digits="2" v-model="seconds"/> + :min="0" :max="59.99" wrap :padding="2" :digits="2" v-model="seconds"/> </div> </template> @@ -93,7 +93,7 @@ div { display: inline-block; } .hours, .minutes { - width: 2em; + width: 2.5em; } .seconds { width: 4em; diff --git a/src/components/TimeTable.vue b/src/components/TimeTable.vue @@ -0,0 +1,292 @@ +<template> + <div class="time-table"> + <table class="results" v-show="!inEditMode"> + <thead> + <tr> + <th colspan="2">Distance</th> + + <th>Time</th> + + <th> + <button class="icon" title="Edit Targets" @click="inEditMode=true" v-blur> + <img alt="" src="@/assets/edit.svg"> + </button> + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in results" :key="index"> + <td> + {{ item.distanceValue.toFixed(2) }} + {{ distanceSymbols[item.distanceUnit] }} + </td> + + <td>in</td> + + <td colspan="2"> + {{ formatDuration(item.time, 0, 2) }} + </td> + </tr> + + <tr v-if="results.length === 0" class="empty-message"> + <td colspan="4"> + There aren't any targets,<br> + click + <img alt="Edit Targets" src="@/assets/edit.svg"> + to add one + </td> + </tr> + </tbody> + </table> + + <table class="targets" v-show="inEditMode"> + <thead> + <tr> + <th>Edit Targets</th> + + <th> + <button class="icon" title="Reset Targets" @click="resetTargets" v-blur> + <img alt="" src="@/assets/rotate-ccw.svg"> + </button> + <button class="icon" title="Close" @click="inEditMode=false" v-blur> + <img alt="" src="@/assets/x.svg"> + </button> + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(item, index) in targets" :key="index"> + <td> + <decimal-input v-model="item.distanceValue" aria-label="Distance Value" + :min="0" :digits="2"/> + <select v-model="item.distanceUnit" aria-label="Distance Unit"> + <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> + {{ value }} + </option> + </select> + </td> + + <td> + <button class="icon" title="Remove Target" @click="targets.splice(index, 1)" v-blur> + <img alt="" src="@/assets/trash-2.svg"> + </button> + </td> + </tr> + + <tr v-if="targets.length === 0" class="empty-message"> + <td colspan="2"> + There aren't any targets,<br> + click + <img alt="Add Target" src="@/assets/plus-circle.svg"> + to add one + </td> + </tr> + </tbody> + + <tfoot> + <tr> + <td colspan="2"> + <button class="icon" title="Add Target" @click="targets.push({distanceValue: 1, + distanceUnit: 'miles'})" v-blur> + <img alt="" src="@/assets/plus-circle.svg"> + </button> + </td> + </tr> + </tfoot> + </table> + </div> +</template> + +<script> +import unitUtils from '@/utils/units'; +import storage from '@/utils/localStorage'; + +import DecimalInput from '@/components/DecimalInput.vue'; + +import blur from '@/directives/blur'; + +export default { + name: 'TimeTable', + + components: { + DecimalInput, + }, + + directives: { + blur, + }, + + props: { + /** + * The method that generates the time table rows + */ + calculateResult: { + type: Function, + required: true, + }, + + /** + * The default time table targets + */ + defaultTargets: { + type: Array, + default: () => [], + }, + + /** + * The localStorage key for the list of targets + */ + storageKey: { + type: String, + default: null, + }, + }, + + data() { + return { + /** + * The names of the distance units + */ + distanceUnits: unitUtils.DISTANCE_UNIT_NAMES, + + /** + * The symbols of the distance units + */ + distanceSymbols: unitUtils.DISTANCE_UNIT_SYMBOLS, + + /** + * The formatDuration method + */ + formatDuration: unitUtils.formatDuration, + + /** + * Whether the table is in edit mode + */ + inEditMode: false, + + /** + * The time table targets + */ + targets: storage.get(this.storageKey, this.defaultTargets), + }; + }, + + computed: { + /** + * The time 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; + }, + }, + + watch: { + /** + * Sort targets + */ + inEditMode() { + this.sortTargets(); + }, + + /** + * Save targets + */ + targets: { + handler(newValue) { + if (this.storageKey !== null) { + storage.set(this.storageKey, newValue); + } + }, + deep: true, + }, + }, + + methods: { + /** + * Restore the default targets + */ + resetTargets() { + // Clone default targets array + this.targets = JSON.parse(JSON.stringify(this.defaultTargets)); + + // Sort targets + this.sortTargets(); + }, + + /** + * Sort the targets by distance + */ + sortTargets() { + this.targets.sort((a, b) => unitUtils.convertDistance(a.distanceValue, a.distanceUnit, + 'meters') - unitUtils.convertDistance(b.distanceValue, b.distanceUnit, 'meters')); + }, + }, + + /** + * Close edit targets table + */ + deactivated() { + this.inEditMode = false; + }, +}; +</script> + +<style scoped> +/* time table */ +.results th:last-child { + text-align: right; +} + +/* edit targets table */ +.targets th:last-child, .targets td:last-child { + text-align: right; +} +.targets td select { + margin-left: 0.2em; +} +.targets tfoot td { + text-align: center !important; + padding: 0.5em 0.2em; +} + +/* general table styles */ +table { + border-collapse: collapse; + width: 100%; + text-align: left; +} +table th, table td { + padding: 0.2em; +} +table button.icon { + height: 2em; + width: 2em; +} + +/* empty table message */ +.empty-message td { + text-align: center !important; +} +.empty-message img { + height: 1em; + width: 1em; +} +@media (prefers-color-scheme: dark) { + .empty-message img { + filter: invert(90%); + } +} +</style> diff --git a/src/directives/blur.js b/src/directives/blur.js @@ -0,0 +1,5 @@ +export default { + inserted(el, binding) { + el.addEventListener(binding.value ? binding.value : 'click', () => el.blur()); + }, +}; diff --git a/src/router/index.js b/src/router/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import VueRouter from 'vue-router'; import Home from '../views/Home.vue'; import PaceCalculator from '../views/PaceCalculator.vue'; +import RaceCalculator from '../views/RaceCalculator.vue'; import UnitCalculator from '../views/UnitCalculator.vue'; Vue.use(VueRouter); @@ -34,6 +35,15 @@ const routes = [ }, }, { + path: '/calculate/races', + name: 'calculate-races', + component: RaceCalculator, + meta: { + title: 'Race Calculator', + back: 'home', + }, + }, + { path: '/calculate/units', name: 'calculate-units', component: UnitCalculator, diff --git a/src/utils/localStorage.js b/src/utils/localStorage.js @@ -0,0 +1,38 @@ +// 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) { + localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value)); +} + +export default { + get, + set, +}; diff --git a/src/utils/races.js b/src/utils/races.js @@ -0,0 +1,164 @@ +/** + * Predict a race time using the Purdy Points Model + * https://www.cs.uml.edu/~phoffman/xcinfo3.html + * @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 + * @returns {Number} The predicted time for the output race in seconds + */ +function PurdyPointsModel(d1, t1, d2) { + // Declare constants + const c1 = 11.15895; + const c2 = 4.304605; + const c3 = 0.5234627; + const c4 = 4.031560; + const c5 = 2.316157; + const r1 = 3.796158e-2; + const r2 = 1.646772e-3; + const r3 = 4.107670e-4; + const r4 = 7.068099e-6; + const r5 = 5.220990e-9; + + // Calculate world record velocity from running curve + const v1 = (-c1 * Math.exp(-r1 * d1)) + + (c2 * Math.exp(-r2 * d1)) + + (c3 * Math.exp(-r3 * d1)) + + (c4 * Math.exp(-r4 * d1)) + + (c5 * Math.exp(-r5 * d1)); + const v2 = (-c1 * Math.exp(-r1 * d2)) + + (c2 * Math.exp(-r2 * d2)) + + (c3 * Math.exp(-r3 * d2)) + + (c4 * Math.exp(-r4 * d2)) + + (c5 * Math.exp(-r5 * d2)); + + // Calculate world record time + const twsec1 = d1 / v1; + const twsec2 = d2 / v2; + + // Calculate constants + const k1 = 0.0654 - (0.00258 * v1); + const k2 = 0.0654 - (0.00258 * v2); + const a1 = 85 / k1; + const a2 = 85 / k2; + const b1 = 1 - (1035 / a1); + const b2 = 1 - (1035 / a2); + + // Calculate Purdy Points for distance 1 + const points = a1 * ((twsec1 / t1) - b1); + + // Calculate time for distance 2 + const seconds = (a2 * twsec2) / (points + (a2 * b2)); + + // Return predicted time + return seconds; +} + +/** + * Calculate a runner's VO2 max from their performance in a race + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in minutes + * @returns {Number} The runner's VO2 max + */ +function VO2Max(d, t) { + const v = d / t; + const result = (-4.6 + (0.182 * v) + (0.000104 * (v ** 2))) + / (0.8 + (0.189 * Math.exp(-0.0128 * t)) + (0.299 * Math.exp(-0.193 * t))); + return result; +} + +/** + * Calculate the derivative with respect to time of the VO2 max curve at a specific point + * @param {Number} d The race distance in meters + * @param {Number} t The finish time in minutes + * @return {Number} The derivative + */ +function VO2MaxDerivative(d, t) { + const result = ((-575000 * (t ** 2)) + (22750 * d * t) + (13 * (d ** 2))) / (125 + * (t ** 2) * (189 * Math.exp((-8 * t) / 625) + (299 * Math.exp((-193 * t) / 1000) + 800))); + return result; +} + +/** + * Predict a race time using the VO2 Max Model + * http://run-down.com/statistics/calcs_explained.php + * @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 {Number} iterations The maximum number of times to refine the prediction + * @returns {Number} The predicted time for the output race in seconds + */ +function VO2MaxModel(d1, t1, d2, iterations = 500) { + // Calculate input VO2 max + const inputVO2 = VO2Max(d1, t1 / 60); + + // Initialize estimate + let estimate = (t1 * d2) / (d1 * 60); + let estimateVO2; + + for (let i = 0; i < iterations; i += 1) { + // Get estimate's VO2 max + estimateVO2 = VO2Max(d2, estimate); + + // Check if estimate is close enough + if (Math.abs(inputVO2 - estimateVO2) < 0.0001) { + break; + } + + // Refine estimate + estimate += (estimateVO2 - inputVO2) / VO2MaxDerivative(d2, estimate); + } + + // Return estimate + return estimate * 60; +} + +/** + * Predict a race time using Dave Cameron's Model + * https://www.cs.uml.edu/~phoffman/cammod.html + * @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 + * @returns {Number} The predicted time for the output race in seconds + */ +function CameronModel(d1, t1, d2) { + const a = 13.49681 - (0.000030363 * d1) + (835.7114 / (d1 ** 0.7905)); + const b = 13.49681 - (0.000030363 * d2) + (835.7114 / (d2 ** 0.7905)); + return (t1 / d1) * (a / b) * d2; +} + +/** + * Predict a race time using Pete Riegel's Model + * https://en.wikipedia.org/wiki/Peter_Riegel + * @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 {Number} c The value of the exponent in the equation + * @returns {Number} The predicted time for the output race in seconds + */ +function RiegelModel(d1, t1, d2, c = 1.06) { + return t1 * ((d2 / d1) ** c); +} + +/** + * Predict a race time by averaging the results of different models + * @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 {Number} c The value of the exponent in Pete Riegel's Model + * @returns {Number} The predicted time for the output race in seconds + */ +function AverageModel(d1, t1, d2, c = 1.06) { + const purdy = PurdyPointsModel(d1, t1, d2); + const vo2max = VO2MaxModel(d1, t1, d2); + const cameron = CameronModel(d1, t1, d2); + const riegel = RiegelModel(d1, t1, d2, c); + return (purdy + vo2max + cameron + riegel) / 4; +} + +export default { + PurdyPointsModel, + VO2MaxModel, + CameronModel, + RiegelModel, + AverageModel, +}; diff --git a/src/views/Home.vue b/src/views/Home.vue @@ -1,16 +1,25 @@ <template> <div class="home"> <p class="description"> - A collection of tools for runners that calculate splits, convert units, and more + A collection of tools for runners and their coaches </p> - <p> - <router-link :to="{ name: 'calculate-paces' }"> - Pace Calculator + <div class="calculators"> + <router-link :to="{ name: 'calculate-paces' }" v-slot="{ navigate }" custom> + <button @click="navigate"> + Pace Calculator + </button> </router-link> - <router-link :to="{ name: 'calculate-units' }"> - Unit Calculator + <router-link :to="{ name: 'calculate-races' }" v-slot="{ navigate }" custom> + <button @click="navigate"> + Race Calculator + </button> </router-link> - </p> + <router-link :to="{ name: 'calculate-units' }" v-slot="{ navigate }" custom> + <button @click="navigate"> + Unit Calculator + </button> + </router-link> + </div> </div> </template> @@ -23,14 +32,30 @@ export default { <style scoped> .home { text-align: center; - max-width: 500px; + max-width: 600px; margin: auto; } .description { font-size: 1.5em; - margin-bottom: 10px; + margin-bottom: 1em; +} +.calculators { + display: flex; + flex-direction: row; +} +.calculators button { + flex-grow: 1; + font-size: 1em; + padding: 0.5em; + margin: 0em 0.3em; } -a { - margin: 0px 10px; +@media only screen and (max-width: 550px) { + .calculators { + flex-direction: column; + } + .calculators button { + margin: 0.3em 0em; + padding: 0.75em 0.5em; + } } </style> diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -15,29 +15,8 @@ <p>is the same pace as running</p> - <table class="output"> - <thead> - <tr> - <th>Distance</th> - <th></th> - <th>Time</th> - </tr> - </thead> - <tbody> - <tr v-for="(item, index) in results" :key="index"> - <td> - {{ item.distanceValue.toFixed(2) }} - {{ distanceSymbols[item.distanceUnit] }} - </td> - - <td>in</td> - - <td> - {{ formatDuration(item.time, 0, 2) }} - </td> - </tr> - </tbody> - </table> + <time-table class="output" :calculate-result="calculatePace" :default-targets="defaultTargets" + storage-key="pace-calculator-targets"/> </div> </template> @@ -47,6 +26,7 @@ import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TimeInput from '@/components/TimeInput.vue'; +import TimeTable from '@/components/TimeTable.vue'; export default { name: 'PaceCalculator', @@ -54,6 +34,7 @@ export default { components: { DecimalInput, TimeInput, + TimeTable, }, data() { @@ -79,19 +60,9 @@ export default { distanceUnits: unitUtils.DISTANCE_UNIT_NAMES, /** - * The symbols of the distance units + * The default output targets */ - distanceSymbols: unitUtils.DISTANCE_UNIT_SYMBOLS, - - /** - * The formatDuration method - */ - formatDuration: unitUtils.formatDuration, - - /** - * The output targets - */ - targets: [ + defaultTargets: [ { distanceValue: 100, distanceUnit: 'meters' }, { distanceValue: 200, distanceUnit: 'meters' }, { distanceValue: 300, distanceUnit: 'meters' }, @@ -104,12 +75,6 @@ export default { { distanceValue: 1600, distanceUnit: 'meters' }, { distanceValue: 3200, distanceUnit: 'meters' }, - { distanceValue: 1, distanceUnit: 'miles' }, - { distanceValue: 2, distanceUnit: 'miles' }, - { distanceValue: 3, distanceUnit: 'miles' }, - { distanceValue: 5, distanceUnit: 'miles' }, - { distanceValue: 10, distanceUnit: 'miles' }, - { distanceValue: 2, distanceUnit: 'kilometers' }, { distanceValue: 3, distanceUnit: 'kilometers' }, { distanceValue: 4, distanceUnit: 'kilometers' }, @@ -119,6 +84,14 @@ export default { { distanceValue: 10, distanceUnit: 'kilometers' }, { distanceValue: 15, distanceUnit: 'kilometers' }, + { distanceValue: 1, distanceUnit: 'miles' }, + { distanceValue: 2, distanceUnit: 'miles' }, + { distanceValue: 3, distanceUnit: 'miles' }, + { distanceValue: 5, distanceUnit: 'miles' }, + { distanceValue: 6, distanceUnit: 'miles' }, + { distanceValue: 8, distanceUnit: 'miles' }, + { distanceValue: 10, distanceUnit: 'miles' }, + { distanceValue: 0.5, distanceUnit: 'marathons' }, { distanceValue: 1, distanceUnit: 'marathons' }, ], @@ -134,34 +107,28 @@ export default { this.inputUnit, unitUtils.DISTANCE_UNITS.meters); return paceUtils.getPace(distance, this.inputTime); }, + }, + methods: { /** - * The output results + * Calculate paces from a target + * @param {Object} target The target + * @returns {Object} The result */ - results() { - // Calculate results - const result = []; - this.targets.forEach((row) => { - // Convert distance into meters - const distance = unitUtils.convertDistance(row.distanceValue, - row.distanceUnit, unitUtils.DISTANCE_UNITS.meters); - - // Calculate time to travel distance at input pace - const time = paceUtils.getTime(this.pace, distance); - - // Add result - result.push({ - distanceValue: row.distanceValue, - distanceUnit: row.distanceUnit, - time, - }); - }); - - // Sort results by time - result.sort((a, b) => a.time - b.time); - - // Return results - return result; + calculatePace(target) { + // Convert distance into meters + const distance = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, + unitUtils.DISTANCE_UNITS.meters); + + // Calculate time to travel distance at input pace + const time = paceUtils.getTime(this.pace, distance); + + // Return result + return { + distanceValue: target.distanceValue, + distanceUnit: target.distanceUnit, + time, + }; }, }, }; @@ -188,20 +155,12 @@ export default { } /* calculator output */ -table { +.output { margin-top: 10px; - border-collapse: collapse; min-width: 300px; } -tr { - border: 0.1em solid #000000; -} -th, td { - padding: 0.2em; - text-align: left; -} -@media only screen and (max-width: 400px) { - table { +@media only screen and (max-width: 500px) { + .output { width: 100%; min-width: 0px; } diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -0,0 +1,149 @@ +<template> + <div class="race-calculator"> + <div class="input"> + Running + <decimal-input v-model="inputDistance" aria-label="Distance value" :min="0" :digits="2"/> + <select v-model="inputUnit" aria-label="Distance unit"> + <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> + {{ value }} + </option> + </select> + in + <time-input v-model="inputTime"/> + </div> + + <p>is approximately equivalent to running</p> + + <time-table class="output" :calculate-result="predictTime" :default-targets="defaultTargets" + storage-key="race-calculator-targets"/> + </div> +</template> + +<script> +import raceUtils from '@/utils/races'; +import unitUtils from '@/utils/units'; + +import DecimalInput from '@/components/DecimalInput.vue'; +import TimeInput from '@/components/TimeInput.vue'; +import TimeTable from '@/components/TimeTable.vue'; + +export default { + name: 'RaceCalculator', + + components: { + DecimalInput, + TimeInput, + TimeTable, + }, + + data() { + return { + /** + * The input distance value + */ + inputDistance: 5, + + /** + * The input distance unit + */ + inputUnit: 'kilometers', + + /** + * The input time value + */ + inputTime: 20 * 60, + + /** + * The names of the distance units + */ + distanceUnits: unitUtils.DISTANCE_UNIT_NAMES, + + /** + * The default output targets + */ + defaultTargets: [ + { distanceValue: 400, distanceUnit: 'meters' }, + { distanceValue: 800, distanceUnit: 'meters' }, + { distanceValue: 1000, distanceUnit: 'meters' }, + { distanceValue: 1200, distanceUnit: 'meters' }, + { distanceValue: 1500, distanceUnit: 'meters' }, + { distanceValue: 1600, distanceUnit: 'meters' }, + { distanceValue: 3200, distanceUnit: 'meters' }, + + { distanceValue: 3, distanceUnit: 'kilometers' }, + { distanceValue: 5, distanceUnit: 'kilometers' }, + { distanceValue: 8, distanceUnit: 'kilometers' }, + { distanceValue: 10, distanceUnit: 'kilometers' }, + { distanceValue: 15, distanceUnit: 'kilometers' }, + + { distanceValue: 1, distanceUnit: 'miles' }, + { distanceValue: 2, distanceUnit: 'miles' }, + { distanceValue: 3, distanceUnit: 'miles' }, + { distanceValue: 5, distanceUnit: 'miles' }, + { distanceValue: 10, distanceUnit: 'miles' }, + + { distanceValue: 0.5, distanceUnit: 'marathons' }, + { distanceValue: 1, distanceUnit: 'marathons' }, + ], + }; + }, + + methods: { + /** + * Predict race times from a target + * @param {Object} target The target + * @returns {Object} The result + */ + predictTime(target) { + // Convert distances into meters + const d1 = unitUtils.convertDistance(this.inputDistance, this.inputUnit, + unitUtils.DISTANCE_UNITS.meters); + const d2 = unitUtils.convertDistance(target.distanceValue, target.distanceUnit, + unitUtils.DISTANCE_UNITS.meters); + + // Get prediction + const time = raceUtils.AverageModel(d1, this.inputTime, d2); + + // Return result + return { + distanceValue: target.distanceValue, + distanceUnit: target.distanceUnit, + time, + }; + }, + }, +}; +</script> + +<style scoped> +/* container */ +.race-calculator { + display: flex; + flex-direction: column; + align-items: center; +} + +/* calculator input */ +.input { + text-align: center; + margin-bottom: 5px; +} +.input>* { + margin-bottom: 5px; /* adds space between wrapped lines */ +} +.input select { + margin-left: 5px; +} + +/* calculator output */ +.output { + margin-top: 10px; + min-width: 300px; +} +@media only screen and (max-width: 500px) { + .output { + width: 100%; + min-width: 0px; + } +} +</style> diff --git a/tests/unit/DecimalInput.spec.js b/tests/unit/DecimalInput.spec.js @@ -1,260 +0,0 @@ -import { expect } from 'chai'; -import { mount } from '@vue/test-utils'; -import DecimalInput from '@/components/DecimalInput.vue'; - -describe('DecimalInput.vue', () => { - it('value should be 0.0 by default', () => { - // Initialize component - const wrapper = mount(DecimalInput); - - // Assert value is 0.0 - expect(wrapper.find('input').element.value).to.equal('0.0'); - }); - - it('should read value prop', () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { value: 1 }, - }); - - // Assert value is 1.0 - expect(wrapper.find('input').element.value).to.equal('1.0'); - }); - - it('up arrow should increment value', async () => { - // Initialize component - const wrapper = mount(DecimalInput); - - // Press up arrow - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is 1.0 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('1.0'); - expect(wrapper.emitted().input).to.deep.equal([[1.0]]); - }); - - it('down arrow should increment value', async () => { - // Initialize component - const wrapper = mount(DecimalInput); - - // Press down arrow - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is -1.0 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('-1.0'); - expect(wrapper.emitted().input).to.deep.equal([[-1.0]]); - }); - - it('should fire input event when value changes', async () => { - // Initialize component - const wrapper = mount(DecimalInput); - - // Set value to 1 - wrapper.find('input').element.value = '1.0'; - await wrapper.find('input').trigger('input'); - - // Assert input event was emitted - expect(wrapper.emitted().input).to.deep.equal([[1.0]]); - }); - - it('should accept numerical values', async () => { - // Initialize component - const wrapper = mount(DecimalInput); - - // Try to set value to 1 - wrapper.find('input').element.value = '1'; - await wrapper.find('input').trigger('input'); - - // Assert value was accepted and input event was emitted - expect(wrapper.find('input').element.value).to.equal('1'); - expect(wrapper.emitted().input).to.deep.equal([[1.0]]); - }); - - it('should accept decimal values', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { value: 1 }, - }); - - // Try to set value to 1.5 - wrapper.find('input').element.value = '1.5'; - await wrapper.find('input').trigger('input'); - - // Assert value was accepted and input event was emitted - expect(wrapper.find('input').element.value).to.equal('1.5'); - expect(wrapper.emitted().input).to.deep.equal([[1.5]]); - }); - - it('should not accept non numerical values', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { value: 1 }, - }); - - // Try to set value to a - wrapper.find('input').element.value = 'a'; - await wrapper.find('input').trigger('input'); - - // Assert value was not accepted and no events were emitted - expect(wrapper.find('input').element.value).to.equal('1.0'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should format input value on blur', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { value: 1, padding: 3, digits: 2 }, - }); - - // Set value to '01' - wrapper.find('input').element.value = '01'; - await wrapper.find('input').trigger('input'); - - // Assert value was not updated and no events were emitted - expect(wrapper.find('input').element.value).to.equal('01'); - expect(wrapper.emitted().input).to.equal(undefined); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value was formatted but no events were emitted - expect(wrapper.find('input').element.value).to.equal('001.00'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should allow input to be empty until blur', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { value: 5 }, - }); - - // Set value to '' - wrapper.find('input').element.value = ''; - await wrapper.find('input').trigger('input'); - - // Assert value is '' and input event was emitted with default value - expect(wrapper.find('input').element.value).to.equal(''); - expect(wrapper.emitted().input).to.deep.equal([[0.0]]); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value is the default value but no new events were emitted - expect(wrapper.find('input').element.value).to.equal('0.0'); - expect(wrapper.emitted().input).to.deep.equal([[0.0]]); - }); - - it('should allow input to be "-" until blur', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { value: 5 }, - }); - - // Set value to '-' - wrapper.find('input').element.value = '-'; - await wrapper.find('input').trigger('input'); - - // Assert value is '-' and input event was emitted with default value - expect(wrapper.find('input').element.value).to.equal('-'); - expect(wrapper.emitted().input).to.deep.equal([[0.0]]); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value is the default value but no new events were emitted - expect(wrapper.find('input').element.value).to.equal('0.0'); - expect(wrapper.emitted().input).to.deep.equal([[0.0]]); - }); - - it('should allow input to be "." until blur', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { value: 5 }, - }); - - // Set value to '.' - wrapper.find('input').element.value = '.'; - await wrapper.find('input').trigger('input'); - - // Assert value is '.' and input event was emitted with default value - expect(wrapper.find('input').element.value).to.equal('.'); - expect(wrapper.emitted().input).to.deep.equal([[0.0]]); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value is the default value but no new events were emitted - expect(wrapper.find('input').element.value).to.equal('0.0'); - expect(wrapper.emitted().input).to.deep.equal([[0.0]]); - }); - - it('default value should be the minimum if 0.0 is not valid', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { value: 3, max: 4, min: 2 }, - }); - - // Set value to '' and trigger blur event so value must be updated - wrapper.find('input').element.value = ''; - await wrapper.find('input').trigger('input'); - await wrapper.find('input').trigger('blur'); - - // Assert value is 2 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('2.0'); - expect(wrapper.emitted().input).to.deep.equal([[2.0]]); - }); - - it('should not allow input to be below the minimum', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { min: 10, value: 20 }, - }); - - // Try to set value to 9, which is below the minimum - wrapper.find('input').element.value = '9.0'; - await wrapper.find('input').trigger('input'); - - // Assert value is 10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('10.0'); - expect(wrapper.emitted().input).to.deep.equal([[10.0]]); - - // Try to decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is still 10 and no new event were emitted - expect(wrapper.find('input').element.value).to.equal('10.0'); - expect(wrapper.emitted().input).to.deep.equal([[10.0]]); - }); - - it('should not allow input to be above the maximum', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { max: 10 }, - }); - - // Try to set value to 11, which is above the maximum - wrapper.find('input').element.value = '11.0'; - await wrapper.find('input').trigger('input'); - - // Assert value is 10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('10.0'); - expect(wrapper.emitted().input).to.deep.equal([[10.0]]); - - // Try to increment value - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is still 10 and no new events were emitted - expect(wrapper.find('input').element.value).to.equal('10.0'); - expect(wrapper.emitted().input).to.deep.equal([[10.0]]); - }); - - it('should format value according to padding and digits props', async () => { - // Initialize component - const wrapper = mount(DecimalInput, { - propsData: { padding: 2, digits: 3 }, - }); - - // Assert value is correctly formatted - expect(wrapper.find('input').element.value).to.equal('00.000'); - }); -}); diff --git a/tests/unit/IntInput.spec.js b/tests/unit/IntInput.spec.js @@ -1,238 +0,0 @@ -import { expect } from 'chai'; -import { mount } from '@vue/test-utils'; -import IntInput from '@/components/IntInput.vue'; - -describe('IntInput.vue', () => { - it('value should be 0 by default', () => { - // Initialize component - const wrapper = mount(IntInput); - - // Assert value is 0 - expect(wrapper.find('input').element.value).to.equal('0'); - }); - - it('should read value prop', () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 1 }, - }); - - // Assert value is 1 - expect(wrapper.find('input').element.value).to.equal('1'); - }); - - it('up arrow should increment value', async () => { - // Initialize component - const wrapper = mount(IntInput); - - // Press up arrow - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is 1 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('1'); - expect(wrapper.emitted().input).to.deep.equal([[1]]); - }); - - it('down arrow should increment value', async () => { - // Initialize component - const wrapper = mount(IntInput); - - // Press down arrow - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is -1 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('-1'); - expect(wrapper.emitted().input).to.deep.equal([[-1]]); - }); - - it('should fire input event when value changes', async () => { - // Initialize component - const wrapper = mount(IntInput); - - // Set value to 1 - wrapper.find('input').element.value = '1'; - await wrapper.find('input').trigger('input'); - - // Assert input event was emitted - expect(wrapper.emitted().input).to.deep.equal([[1]]); - }); - - it('should accept numerical values', async () => { - // Initialize component - const wrapper = mount(IntInput); - - // Try to set value to 1 - wrapper.find('input').element.value = '1'; - await wrapper.find('input').trigger('input'); - - // Assert value was accepted and input event was emitted - expect(wrapper.find('input').element.value).to.equal('1'); - expect(wrapper.emitted().input).to.deep.equal([[1]]); - }); - - it('should not accept decimal values', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 1 }, - }); - - // Try to set value to 1.5 - wrapper.find('input').element.value = '1.5'; - await wrapper.find('input').trigger('input'); - - // Assert value was not accepted and no events were emitted - expect(wrapper.find('input').element.value).to.equal('1'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should not accept non numerical values', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 1 }, - }); - - // Try to set value to a - wrapper.find('input').element.value = 'a'; - await wrapper.find('input').trigger('input'); - - // Assert value was not accepted and no events were emitted - expect(wrapper.find('input').element.value).to.equal('1'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should format input value on blur', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 1, padding: 3 }, - }); - - // Set value to '01' - wrapper.find('input').element.value = '01'; - await wrapper.find('input').trigger('input'); - - // Assert value was not updated and no events were emitted - expect(wrapper.find('input').element.value).to.equal('01'); - expect(wrapper.emitted().input).to.equal(undefined); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value was formatted but no events were emitted - expect(wrapper.find('input').element.value).to.equal('001'); - expect(wrapper.emitted().input).to.equal(undefined); - }); - - it('should allow input to be empty until blur', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 5 }, - }); - - // Set value to '' - wrapper.find('input').element.value = ''; - await wrapper.find('input').trigger('input'); - - // Assert value is '' and input event was emitted with default value - expect(wrapper.find('input').element.value).to.equal(''); - expect(wrapper.emitted().input).to.deep.equal([[0]]); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value is the default value but no new events were emitted - expect(wrapper.find('input').element.value).to.equal('0'); - expect(wrapper.emitted().input).to.deep.equal([[0]]); - }); - - it('should allow input to be "-" until blur', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 5 }, - }); - - // Set value to '-' - wrapper.find('input').element.value = '-'; - await wrapper.find('input').trigger('input'); - - // Assert value is '-' and input event was emitted with default value - expect(wrapper.find('input').element.value).to.equal('-'); - expect(wrapper.emitted().input).to.deep.equal([[0]]); - - // Trigger blur event - await wrapper.find('input').trigger('blur'); - - // Assert value is the default value but no new events were emitted - expect(wrapper.find('input').element.value).to.equal('0'); - expect(wrapper.emitted().input).to.deep.equal([[0]]); - }); - - it('default value should be the minimum if 0 is not valid', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { value: 3, max: 4, min: 2 }, - }); - - // Set value to '' and trigger blur event so value must be updated - wrapper.find('input').element.value = ''; - await wrapper.find('input').trigger('input'); - await wrapper.find('input').trigger('blur'); - - // Assert value is 2 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('2'); - expect(wrapper.emitted().input).to.deep.equal([[2]]); - }); - - it('should not allow input to be below the minimum', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { min: 10, value: 20 }, - }); - - // Try to set value to 9, which is below the minimum - wrapper.find('input').element.value = '9'; - await wrapper.find('input').trigger('input'); - - // Assert value is 10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - - // Try to decrement value - await wrapper.trigger('keydown', { key: 'ArrowDown' }); - - // Assert value is still 10 and no new event were emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - }); - - it('should not allow input to be above the maximum', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { max: 10 }, - }); - - // Try to set value to 11, which is above the maximum - wrapper.find('input').element.value = '11'; - await wrapper.find('input').trigger('input'); - - // Assert value is 10 and input event was emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - - // Try to increment value - await wrapper.trigger('keydown', { key: 'ArrowUp' }); - - // Assert value is still 10 and no new events were emitted - expect(wrapper.find('input').element.value).to.equal('10'); - expect(wrapper.emitted().input).to.deep.equal([[10]]); - }); - - it('should format value according to padding prop', async () => { - // Initialize component - const wrapper = mount(IntInput, { - propsData: { padding: 2 }, - }); - - // Assert value is correctly formatted - expect(wrapper.find('input').element.value).to.equal('00'); - }); -}); diff --git a/tests/unit/PaceCalculator.spec.js b/tests/unit/PaceCalculator.spec.js @@ -1,67 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -import { expect } from 'chai'; -import { shallowMount } from '@vue/test-utils'; -import PaceCalculator from '@/views/PaceCalculator.vue'; - -describe('PaceCalculator.vue', () => { - it('results should be correct', async () => { - // Initialize component - const wrapper = shallowMount(PaceCalculator); - - // Override input values - await wrapper.setData({ - inputDistance: 1, - inputUnit: 'kilometers', - inputTime: 100, - }); - - // Override targets - await wrapper.setData({ - targets: [ - { distanceValue: 10, distanceUnit: 'meters' }, - { distanceValue: 20, distanceUnit: 'meters' }, - { distanceValue: 100, distanceUnit: 'meters' }, - { distanceValue: 1, distanceUnit: 'kilometers' }, - ], - }); - - // Assert results are correct - expect(wrapper.vm._computedWatchers.results.value).to.deep.equal([ - { distanceValue: 10, distanceUnit: 'meters', time: 1 }, - { distanceValue: 20, distanceUnit: 'meters', time: 2 }, - { distanceValue: 100, distanceUnit: 'meters', time: 10 }, - { distanceValue: 1, distanceUnit: 'kilometers', time: 100 }, - ]); - }); - - it('results should be sorted by time', async () => { - // Initialize component - const wrapper = shallowMount(PaceCalculator); - - // Override input values - await wrapper.setData({ - inputDistance: 1, - inputUnit: 'kilometers', - inputTime: 100, - }); - - // Override targets - await wrapper.setData({ - targets: [ - { distanceValue: 20, distanceUnit: 'meters' }, - { distanceValue: 100, distanceUnit: 'meters' }, - { distanceValue: 1, distanceUnit: 'kilometers' }, - { distanceValue: 10, distanceUnit: 'meters' }, - ], - }); - - // Assert results are correct - expect(wrapper.vm._computedWatchers.results.value).to.deep.equal([ - { distanceValue: 10, distanceUnit: 'meters', time: 1 }, - { distanceValue: 20, distanceUnit: 'meters', time: 2 }, - { distanceValue: 100, distanceUnit: 'meters', time: 10 }, - { distanceValue: 1, distanceUnit: 'kilometers', time: 100 }, - ]); - }); -}); diff --git a/tests/unit/TimeInput.spec.js b/tests/unit/TimeInput.spec.js @@ -1,59 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -import { expect } from 'chai'; -import { shallowMount } from '@vue/test-utils'; -import TimeInput from '@/components/TimeInput.vue'; - -describe('TimeInput.vue', () => { - it('value should be 0:00:0.00 by default', () => { - // Initialize component - const wrapper = shallowMount(TimeInput); - - // Assert value is 0:00:00.00 - expect(wrapper.vm._data.hours).to.equal(0); - expect(wrapper.vm._data.minutes).to.equal(0); - expect(wrapper.vm._data.seconds).to.equal(0.00); - }); - - it('should read value prop', () => { - // Initialize component - const wrapper = shallowMount(TimeInput, { - propsData: { value: 3600 + 60 + 1.5 }, - }); - - // Assert value is 1:01:01.50 - expect(wrapper.vm._data.hours).to.equal(1); - expect(wrapper.vm._data.minutes).to.equal(1); - expect(wrapper.vm._data.seconds).to.equal(1.50); - }); - - it('should update when value prop changes', async () => { - // Initialize component - const wrapper = shallowMount(TimeInput); - - // Set value prop to 60 - await wrapper.setProps({ value: 60 }); - - // Assert value is 0:01:00.00 - expect(wrapper.vm._data.hours).to.equal(0); - expect(wrapper.vm._data.minutes).to.equal(1); - expect(wrapper.vm._data.seconds).to.equal(0.00); - }); - - it('should emit input event when value changes', async () => { - // Initialize component - const wrapper = shallowMount(TimeInput); - - // Change value to 1:00:00.00 - await wrapper.setData({ hours: 1 }); - - // Assert input event was emitted - expect(wrapper.emitted().input).to.deep.equal([[3600.00]]); - - // Change value to 1:00:01.50 - await wrapper.setData({ seconds: 1.5 }); - - // Assert another input event was emitted - expect(wrapper.emitted().input).to.deep.equal([[3600.00], [3601.50]]); - }); -}); diff --git a/tests/unit/UnitCalculator.spec.js b/tests/unit/UnitCalculator.spec.js @@ -1,100 +0,0 @@ -/* eslint-disable no-underscore-dangle */ - -import { expect } from 'chai'; -import { shallowMount } from '@vue/test-utils'; -import UnitCalculator from '@/views/UnitCalculator.vue'; - -describe('UnitCalculator.vue', () => { - it('should correctly update controls when category changes', async () => { - // Initialize component - const wrapper = shallowMount(UnitCalculator); - - // Change category - await wrapper.setData({ category: 'time' }); - - // Assert controls are correct - expect(wrapper.vm._data.inputValue).to.equal(1); - expect(wrapper.vm._data.inputUnit).to.equal('seconds'); - expect(wrapper.vm._data.outputUnit).to.equal('hh:mm:ss'); - - // Change category - await wrapper.setData({ category: 'speed_and_pace' }); - - // Assert controls are correct - expect(wrapper.vm._data.inputValue).to.equal(1); - expect(wrapper.vm._data.inputUnit).to.equal('miles_per_hour'); - expect(wrapper.vm._data.outputUnit).to.equal('seconds_per_mile'); - - // Change category - await wrapper.setData({ category: 'distance' }); - - // Assert controls are correct - expect(wrapper.vm._data.inputValue).to.equal(1); - expect(wrapper.vm._data.inputUnit).to.equal('miles'); - expect(wrapper.vm._data.outputUnit).to.equal('meters'); - }); - - it('outputValue should be correct', async () => { - // Initialize component - const wrapper = shallowMount(UnitCalculator); - - // Change category and update input - await wrapper.setData({ category: 'distance' }); - await wrapper.setData({ - inputValue: 2, - inputUnit: 'kilometers', - outputUnit: 'meters', - }); - - // Assert controls are correct - expect(wrapper.vm._computedWatchers.outputValue.value).to.equal(2000); - - // Change category and update input - await wrapper.setData({ category: 'time' }); - await wrapper.setData({ - inputValue: 3, - inputUnit: 'minutes', - outputUnit: 'seconds', - }); - - // Assert controls are correct - expect(wrapper.vm._computedWatchers.outputValue.value).to.equal(3 * 60); - - // Change category and update input - await wrapper.setData({ category: 'speed_and_pace' }); - await wrapper.setData({ - inputValue: 2, - inputUnit: 'miles_per_hour', - outputUnit: 'seconds_per_mile', - }); - - // Assert controls are correct - expect(wrapper.vm._computedWatchers.outputValue.value).to.be.closeTo(30 * 60, 0.001); - }); - - it('should correctly convert to and from hh:mm:ss', async () => { - // Initialize component - const wrapper = shallowMount(UnitCalculator); - - // Change category and update input - await wrapper.setData({ category: 'time' }); - await wrapper.setData({ - inputValue: 60, - inputUnit: 'hh:mm:ss', - outputUnit: 'minutes', - }); - - // Assert controls are correct - expect(wrapper.vm._computedWatchers.outputValue.value).to.equal(1); - - // Update input - await wrapper.setData({ - inputValue: 1, - inputUnit: 'minutes', - outputUnit: 'hh:mm:ss', - }); - - // Assert controls are correct - expect(wrapper.vm._computedWatchers.outputValue.value).to.equal(60); - }); -}); diff --git a/tests/unit/components/DecimalInput.spec.js b/tests/unit/components/DecimalInput.spec.js @@ -0,0 +1,342 @@ +import { expect } from 'chai'; +import { mount } from '@vue/test-utils'; +import DecimalInput from '@/components/DecimalInput.vue'; + +describe('components/DecimalInput.vue', () => { + it('value should be 0.0 by default', () => { + // Initialize component + const wrapper = mount(DecimalInput); + + // Assert value is 0.0 + expect(wrapper.find('input').element.value).to.equal('0.0'); + }); + + it('should read value prop', () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 1 }, + }); + + // Assert value is 1.0 + expect(wrapper.find('input').element.value).to.equal('1.0'); + }); + + it('up arrow should increment value by step', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { step: 0.2 }, + }); + + // Press up arrow + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 0.2 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('0.2'); + expect(wrapper.emitted().input).to.deep.equal([[0.2]]); + }); + + it('down arrow should increment value by step', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { step: 0.2 }, + }); + + // Press down arrow + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is -0.2 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('-0.2'); + expect(wrapper.emitted().input).to.deep.equal([[-0.2]]); + }); + + it('should fire input event when value changes', async () => { + // Initialize component + const wrapper = mount(DecimalInput); + + // Set value to 1 + wrapper.find('input').element.value = '1.0'; + await wrapper.find('input').trigger('input'); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([[1.0]]); + }); + + it('should accept numerical values', async () => { + // Initialize component + const wrapper = mount(DecimalInput); + + // Try to set value to 1 + wrapper.find('input').element.value = '1'; + await wrapper.find('input').trigger('input'); + + // Assert value was accepted and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1'); + expect(wrapper.emitted().input).to.deep.equal([[1.0]]); + }); + + it('should accept decimal values', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 1 }, + }); + + // Try to set value to 1.5 + wrapper.find('input').element.value = '1.5'; + await wrapper.find('input').trigger('input'); + + // Assert value was accepted and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1.5'); + expect(wrapper.emitted().input).to.deep.equal([[1.5]]); + }); + + it('should not accept non numerical values', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 1 }, + }); + + // Try to set value to a + wrapper.find('input').element.value = 'a'; + await wrapper.find('input').trigger('input'); + + // Assert value was not accepted and no events were emitted + expect(wrapper.find('input').element.value).to.equal('1.0'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should format input value on blur', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 1, padding: 3, digits: 2 }, + }); + + // Set value to '01' + wrapper.find('input').element.value = '01'; + await wrapper.find('input').trigger('input'); + + // Assert value was not updated and no events were emitted + expect(wrapper.find('input').element.value).to.equal('01'); + expect(wrapper.emitted().input).to.equal(undefined); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value was formatted but no events were emitted + expect(wrapper.find('input').element.value).to.equal('001.00'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should allow input to be empty until blur', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 5 }, + }); + + // Set value to '' + wrapper.find('input').element.value = ''; + await wrapper.find('input').trigger('input'); + + // Assert value is '' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal(''); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0.0'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + }); + + it('should allow input to be "-" until blur', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 5 }, + }); + + // Set value to '-' + wrapper.find('input').element.value = '-'; + await wrapper.find('input').trigger('input'); + + // Assert value is '-' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal('-'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0.0'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + }); + + it('should allow input to be "." until blur', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 5 }, + }); + + // Set value to '.' + wrapper.find('input').element.value = '.'; + await wrapper.find('input').trigger('input'); + + // Assert value is '.' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal('.'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0.0'); + expect(wrapper.emitted().input).to.deep.equal([[0.0]]); + }); + + it('default value should be the minimum if 0.0 is not valid', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { value: 3, max: 4, min: 2 }, + }); + + // Set value to '' and trigger blur event so value must be updated + wrapper.find('input').element.value = ''; + await wrapper.find('input').trigger('input'); + await wrapper.find('input').trigger('blur'); + + // Assert value is 2 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('2.0'); + expect(wrapper.emitted().input).to.deep.equal([[2.0]]); + }); + + it('should not allow input to be below the minimum', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { min: 10, value: 20 }, + }); + + // Try to set value to 9, which is below the minimum + wrapper.find('input').element.value = '9.0'; + await wrapper.find('input').trigger('input'); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10.0'); + expect(wrapper.emitted().input).to.deep.equal([[10.0]]); + + // Try to decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is still 10 and no new event were emitted + expect(wrapper.find('input').element.value).to.equal('10.0'); + expect(wrapper.emitted().input).to.deep.equal([[10.0]]); + }); + + it('should not allow input to be above the maximum', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { max: 10 }, + }); + + // Try to set value to 11, which is above the maximum + wrapper.find('input').element.value = '11.0'; + await wrapper.find('input').trigger('input'); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10.0'); + expect(wrapper.emitted().input).to.deep.equal([[10.0]]); + + // Try to increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is still 10 and no new events were emitted + expect(wrapper.find('input').element.value).to.equal('10.0'); + expect(wrapper.emitted().input).to.deep.equal([[10.0]]); + }); + + it('should not wrap to the maximum if it is null', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { + min: -1.0, max: null, value: -1.0, step: 0.2, wrap: true, + }, + }); + + // Try to decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is still -1.0 and no events were emitted + expect(wrapper.find('input').element.value).to.equal('-1.0'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should not wrap to the minimum if it is null', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { + min: null, max: 1.0, value: 1.0, step: 0.2, wrap: true, + }, + }); + + // Try to increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is still 1.0 and no events were emitted + expect(wrapper.find('input').element.value).to.equal('1.0'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should correctly wrap from the minimum to maximum', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { + min: -1.0, max: 1.0, value: -0.9, step: 0.2, wrap: true, + }, + }); + + // Decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is -1.0 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('-1.0'); + expect(wrapper.emitted().input).to.deep.equal([[-1.0]]); + + // Decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is 1.0 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1.0'); + expect(wrapper.emitted().input).to.deep.equal([[-1.0], [1.0]]); + }); + + it('should correctly wrap from the maximum to minimum', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { + min: -1.0, max: 1.0, value: 0.9, step: 0.2, wrap: true, + }, + }); + + // Increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 1.0 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1.0'); + expect(wrapper.emitted().input).to.deep.equal([[1.0]]); + + // Increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is -1.0 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('-1.0'); + expect(wrapper.emitted().input).to.deep.equal([[1.0], [-1.0]]); + }); + + it('should format value according to padding and digits props', async () => { + // Initialize component + const wrapper = mount(DecimalInput, { + propsData: { padding: 2, digits: 3 }, + }); + + // Assert value is correctly formatted + expect(wrapper.find('input').element.value).to.equal('00.000'); + }); +}); diff --git a/tests/unit/components/IntInput.spec.js b/tests/unit/components/IntInput.spec.js @@ -0,0 +1,320 @@ +import { expect } from 'chai'; +import { mount } from '@vue/test-utils'; +import IntInput from '@/components/IntInput.vue'; + +describe('components/IntInput.vue', () => { + it('value should be 0 by default', () => { + // Initialize component + const wrapper = mount(IntInput); + + // Assert value is 0 + expect(wrapper.find('input').element.value).to.equal('0'); + }); + + it('should read value prop', () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { value: 1 }, + }); + + // Assert value is 1 + expect(wrapper.find('input').element.value).to.equal('1'); + }); + + it('up arrow should increment value by step', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { step: 2 }, + }); + + // Press up arrow + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 1 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('2'); + expect(wrapper.emitted().input).to.deep.equal([[2]]); + }); + + it('down arrow should increment value by step', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { step: 2 }, + }); + + // Press down arrow + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is -1 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('-2'); + expect(wrapper.emitted().input).to.deep.equal([[-2]]); + }); + + it('should fire input event when value changes', async () => { + // Initialize component + const wrapper = mount(IntInput); + + // Set value to 1 + wrapper.find('input').element.value = '1'; + await wrapper.find('input').trigger('input'); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([[1]]); + }); + + it('should accept numerical values', async () => { + // Initialize component + const wrapper = mount(IntInput); + + // Try to set value to 1 + wrapper.find('input').element.value = '1'; + await wrapper.find('input').trigger('input'); + + // Assert value was accepted and input event was emitted + expect(wrapper.find('input').element.value).to.equal('1'); + expect(wrapper.emitted().input).to.deep.equal([[1]]); + }); + + it('should not accept decimal values', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { value: 1 }, + }); + + // Try to set value to 1.5 + wrapper.find('input').element.value = '1.5'; + await wrapper.find('input').trigger('input'); + + // Assert value was not accepted and no events were emitted + expect(wrapper.find('input').element.value).to.equal('1'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should not accept non numerical values', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { value: 1 }, + }); + + // Try to set value to a + wrapper.find('input').element.value = 'a'; + await wrapper.find('input').trigger('input'); + + // Assert value was not accepted and no events were emitted + expect(wrapper.find('input').element.value).to.equal('1'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should format input value on blur', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { value: 1, padding: 3 }, + }); + + // Set value to '01' + wrapper.find('input').element.value = '01'; + await wrapper.find('input').trigger('input'); + + // Assert value was not updated and no events were emitted + expect(wrapper.find('input').element.value).to.equal('01'); + expect(wrapper.emitted().input).to.equal(undefined); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value was formatted but no events were emitted + expect(wrapper.find('input').element.value).to.equal('001'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should allow input to be empty until blur', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { value: 5 }, + }); + + // Set value to '' + wrapper.find('input').element.value = ''; + await wrapper.find('input').trigger('input'); + + // Assert value is '' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal(''); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0'); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + }); + + it('should allow input to be "-" until blur', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { value: 5 }, + }); + + // Set value to '-' + wrapper.find('input').element.value = '-'; + await wrapper.find('input').trigger('input'); + + // Assert value is '-' and input event was emitted with default value + expect(wrapper.find('input').element.value).to.equal('-'); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + + // Trigger blur event + await wrapper.find('input').trigger('blur'); + + // Assert value is the default value but no new events were emitted + expect(wrapper.find('input').element.value).to.equal('0'); + expect(wrapper.emitted().input).to.deep.equal([[0]]); + }); + + it('default value should be the minimum if 0 is not valid', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { value: 3, max: 4, min: 2 }, + }); + + // Set value to '' and trigger blur event so value must be updated + wrapper.find('input').element.value = ''; + await wrapper.find('input').trigger('input'); + await wrapper.find('input').trigger('blur'); + + // Assert value is 2 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('2'); + expect(wrapper.emitted().input).to.deep.equal([[2]]); + }); + + it('should not allow input to be below the minimum', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { min: 10, value: 20 }, + }); + + // Try to set value to 9, which is below the minimum + wrapper.find('input').element.value = '9'; + await wrapper.find('input').trigger('input'); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + + // Try to decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is still 10 and no new event were emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + }); + + it('should not allow input to be above the maximum', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { max: 10 }, + }); + + // Try to set value to 11, which is above the maximum + wrapper.find('input').element.value = '11'; + await wrapper.find('input').trigger('input'); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + + // Try to increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is still 10 and no new events were emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + }); + + it('should not wrap to the maximum if it is null', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { + min: -10, max: null, value: -10, step: 2, wrap: true, + }, + }); + + // Try to decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is still -10 and no events were emitted + expect(wrapper.find('input').element.value).to.equal('-10'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should not wrap to the minimum if it is null', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { + min: null, max: 10, value: 10, step: 2, wrap: true, + }, + }); + + // Try to increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is still 10 and no events were emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.equal(undefined); + }); + + it('should correctly wrap from the minimum to maximum', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { + min: -10, max: 10, value: -9, step: 2, wrap: true, + }, + }); + + // Decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is -10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('-10'); + expect(wrapper.emitted().input).to.deep.equal([[-10]]); + + // Decrement value + await wrapper.trigger('keydown', { key: 'ArrowDown' }); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[-10], [10]]); + }); + + it('should correctly wrap from the maximum to minimum', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { + min: -10, max: 10, value: 9, step: 2, wrap: true, + }, + }); + + // Increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is 10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('10'); + expect(wrapper.emitted().input).to.deep.equal([[10]]); + + // Increment value + await wrapper.trigger('keydown', { key: 'ArrowUp' }); + + // Assert value is -10 and input event was emitted + expect(wrapper.find('input').element.value).to.equal('-10'); + expect(wrapper.emitted().input).to.deep.equal([[10], [-10]]); + }); + + it('should format value according to padding prop', async () => { + // Initialize component + const wrapper = mount(IntInput, { + propsData: { padding: 2 }, + }); + + // Assert value is correctly formatted + expect(wrapper.find('input').element.value).to.equal('00'); + }); +}); diff --git a/tests/unit/components/TimeInput.spec.js b/tests/unit/components/TimeInput.spec.js @@ -0,0 +1,59 @@ +/* eslint-disable no-underscore-dangle */ + +import { expect } from 'chai'; +import { shallowMount } from '@vue/test-utils'; +import TimeInput from '@/components/TimeInput.vue'; + +describe('components/TimeInput.vue', () => { + it('value should be 0:00:0.00 by default', () => { + // Initialize component + const wrapper = shallowMount(TimeInput); + + // Assert value is 0:00:00.00 + expect(wrapper.vm._data.hours).to.equal(0); + expect(wrapper.vm._data.minutes).to.equal(0); + expect(wrapper.vm._data.seconds).to.equal(0.00); + }); + + it('should read value prop', () => { + // Initialize component + const wrapper = shallowMount(TimeInput, { + propsData: { value: 3600 + 60 + 1.5 }, + }); + + // Assert value is 1:01:01.50 + expect(wrapper.vm._data.hours).to.equal(1); + expect(wrapper.vm._data.minutes).to.equal(1); + expect(wrapper.vm._data.seconds).to.equal(1.50); + }); + + it('should update when value prop changes', async () => { + // Initialize component + const wrapper = shallowMount(TimeInput); + + // Set value prop to 60 + await wrapper.setProps({ value: 60 }); + + // Assert value is 0:01:00.00 + expect(wrapper.vm._data.hours).to.equal(0); + expect(wrapper.vm._data.minutes).to.equal(1); + expect(wrapper.vm._data.seconds).to.equal(0.00); + }); + + it('should emit input event when value changes', async () => { + // Initialize component + const wrapper = shallowMount(TimeInput); + + // Change value to 1:00:00.00 + await wrapper.setData({ hours: 1 }); + + // Assert input event was emitted + expect(wrapper.emitted().input).to.deep.equal([[3600.00]]); + + // Change value to 1:00:01.50 + await wrapper.setData({ seconds: 1.5 }); + + // Assert another input event was emitted + expect(wrapper.emitted().input).to.deep.equal([[3600.00], [3601.50]]); + }); +}); diff --git a/tests/unit/components/TimeTable.spec.js b/tests/unit/components/TimeTable.spec.js @@ -0,0 +1,34 @@ +/* eslint-disable no-underscore-dangle */ + +import { expect } from 'chai'; +import { shallowMount } from '@vue/test-utils'; +import TimeTable from '@/components/TimeTable.vue'; + +describe('components/TimeTable.vue', () => { + it('results should be correct and sorted by time', () => { + // Initialize component + const wrapper = shallowMount(TimeTable, { + propsData: { + calculateResult: (row) => ({ + distanceValue: row.distanceValue, + distanceUnit: row.distanceUnit, + time: row.distanceValue + 1, + }), + defaultTargets: [ + { distanceValue: 20, distanceUnit: 'meters' }, + { distanceValue: 100, distanceUnit: 'meters' }, + { distanceValue: 1, distanceUnit: 'kilometers' }, + { distanceValue: 10, distanceUnit: 'meters' }, + ], + }, + }); + + // Assert results are correct + expect(wrapper.vm._computedWatchers.results.value).to.deep.equal([ + { distanceValue: 1, distanceUnit: 'kilometers', time: 2 }, + { distanceValue: 10, distanceUnit: 'meters', time: 11 }, + { distanceValue: 20, distanceUnit: 'meters', time: 21 }, + { distanceValue: 100, distanceUnit: 'meters', time: 101 }, + ]); + }); +}); diff --git a/tests/unit/pace.spec.js b/tests/unit/pace.spec.js @@ -1,22 +0,0 @@ -import { expect } from 'chai'; -import pace from '@/utils/paces'; - -describe('utils/pace.js', () => { - describe('getPace method', () => { - it('2 meters in 6 seconds should equal 3 seconds per meter', () => { - expect(pace.getPace(2, 6)).to.equal(3); - }); - }); - - describe('getTime method', () => { - it('2 meters at 3 seconds per meter should equal 6 seconds', () => { - expect(pace.getTime(3, 2)).to.equal(6); - }); - }); - - describe('getDistance method', () => { - it('6 seconds at 3 seconds per meter should equal 2 meters', () => { - expect(pace.getDistance(3, 6)).to.equal(2); - }); - }); -}); diff --git a/tests/unit/utils/paces.spec.js b/tests/unit/utils/paces.spec.js @@ -0,0 +1,22 @@ +import { expect } from 'chai'; +import paces from '@/utils/paces'; + +describe('utils/paces.js', () => { + describe('getPace method', () => { + it('2 meters in 6 seconds should equal 3 seconds per meter', () => { + expect(paces.getPace(2, 6)).to.equal(3); + }); + }); + + describe('getTime method', () => { + it('2 meters at 3 seconds per meter should equal 6 seconds', () => { + expect(paces.getTime(3, 2)).to.equal(6); + }); + }); + + describe('getDistance method', () => { + it('6 seconds at 3 seconds per meter should equal 2 meters', () => { + expect(paces.getDistance(3, 6)).to.equal(2); + }); + }); +}); diff --git a/tests/unit/utils/races.spec.js b/tests/unit/utils/races.spec.js @@ -0,0 +1,68 @@ +import { expect } from 'chai'; +import raceUtils from '@/utils/races'; + +describe('utils/races.js', () => { + describe('PurdyPointsModel method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.PurdyPointsModel(5000, 1200, 10000); + expect(result).to.be.closeTo(2490, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.PurdyPointsModel(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('VO2MaxModel method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.VO2MaxModel(5000, 1200, 10000); + expect(result).to.be.closeTo(2488, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.VO2MaxModel(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('CameronModel method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.CameronModel(5000, 1200, 10000); + expect(result).to.be.closeTo(2500, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.CameronModel(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('RiegelModel method', () => { + it('Predictions should be approximately correct', () => { + const result = raceUtils.RiegelModel(5000, 1200, 10000); + expect(result).to.be.closeTo(2502, 1); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.RiegelModel(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); + + describe('AverageModel method', () => { + it('Predictions should be correct', () => { + const result = raceUtils.AverageModel(5000, 1200, 10000); + const riegel = raceUtils.RiegelModel(5000, 1200, 10000); + const cameron = raceUtils.CameronModel(5000, 1200, 10000); + const purdyPoints = raceUtils.PurdyPointsModel(5000, 1200, 10000); + const vo2Max = raceUtils.VO2MaxModel(5000, 1200, 10000); + expect(result).to.equal((riegel + cameron + purdyPoints + vo2Max) / 4); + }); + + it('Should predict identical times for itentical distances', () => { + const result = raceUtils.AverageModel(5000, 1200, 5000); + expect(result).to.be.closeTo(1200, 0.001); + }); + }); +}); diff --git a/tests/unit/units.spec.js b/tests/unit/utils/units.spec.js diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js @@ -0,0 +1,32 @@ +/* eslint-disable no-underscore-dangle */ + +import { expect } from 'chai'; +import { shallowMount } from '@vue/test-utils'; +import PaceCalculator from '@/views/PaceCalculator.vue'; + +describe('views/PaceCalculator.vue', () => { + it('should correctly calculate paces', async () => { + // Initialize component + const wrapper = shallowMount(PaceCalculator); + + // Override input values + await wrapper.setData({ + inputDistance: 1, + inputUnit: 'kilometers', + inputTime: 100, + }); + + // Calculate paces + const result = wrapper.vm.calculatePace({ + distanceValue: 20, + distanceUnit: 'meters', + }); + + // Assert result is correct + expect(result).to.deep.equal({ + distanceValue: 20, + distanceUnit: 'meters', + time: 2, + }); + }); +}); diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -0,0 +1,34 @@ +/* eslint-disable no-underscore-dangle */ + +import { expect } from 'chai'; +import { shallowMount } from '@vue/test-utils'; +import raceUtils from '@/utils/races'; +import RaceCalculator from '@/views/RaceCalculator.vue'; + +describe('views/RaceCalculator.vue', () => { + it('should correctly predict race times', async () => { + // Initialize component + const wrapper = shallowMount(RaceCalculator); + + // Override input values + await wrapper.setData({ + inputDistance: 5, + inputUnit: 'kilometers', + inputTime: 20 * 60, + }); + + // Predict race times + const result = wrapper.vm.predictTime({ + distanceValue: 10, + distanceUnit: 'kilometers', + }); + + // Assert result is correct + const prediction = raceUtils.AverageModel(5000, 1200, 10000); + expect(result).to.deep.equal({ + distanceValue: 10, + distanceUnit: 'kilometers', + time: prediction, + }); + }); +}); diff --git a/tests/unit/views/UnitCalculator.spec.js b/tests/unit/views/UnitCalculator.spec.js @@ -0,0 +1,100 @@ +/* eslint-disable no-underscore-dangle */ + +import { expect } from 'chai'; +import { shallowMount } from '@vue/test-utils'; +import UnitCalculator from '@/views/UnitCalculator.vue'; + +describe('views/UnitCalculator.vue', () => { + it('should correctly update controls when category changes', async () => { + // Initialize component + const wrapper = shallowMount(UnitCalculator); + + // Change category + await wrapper.setData({ category: 'time' }); + + // Assert controls are correct + expect(wrapper.vm._data.inputValue).to.equal(1); + expect(wrapper.vm._data.inputUnit).to.equal('seconds'); + expect(wrapper.vm._data.outputUnit).to.equal('hh:mm:ss'); + + // Change category + await wrapper.setData({ category: 'speed_and_pace' }); + + // Assert controls are correct + expect(wrapper.vm._data.inputValue).to.equal(1); + expect(wrapper.vm._data.inputUnit).to.equal('miles_per_hour'); + expect(wrapper.vm._data.outputUnit).to.equal('seconds_per_mile'); + + // Change category + await wrapper.setData({ category: 'distance' }); + + // Assert controls are correct + expect(wrapper.vm._data.inputValue).to.equal(1); + expect(wrapper.vm._data.inputUnit).to.equal('miles'); + expect(wrapper.vm._data.outputUnit).to.equal('meters'); + }); + + it('outputValue should be correct', async () => { + // Initialize component + const wrapper = shallowMount(UnitCalculator); + + // Change category and update input + await wrapper.setData({ category: 'distance' }); + await wrapper.setData({ + inputValue: 2, + inputUnit: 'kilometers', + outputUnit: 'meters', + }); + + // Assert controls are correct + expect(wrapper.vm._computedWatchers.outputValue.value).to.equal(2000); + + // Change category and update input + await wrapper.setData({ category: 'time' }); + await wrapper.setData({ + inputValue: 3, + inputUnit: 'minutes', + outputUnit: 'seconds', + }); + + // Assert controls are correct + expect(wrapper.vm._computedWatchers.outputValue.value).to.equal(3 * 60); + + // Change category and update input + await wrapper.setData({ category: 'speed_and_pace' }); + await wrapper.setData({ + inputValue: 2, + inputUnit: 'miles_per_hour', + outputUnit: 'seconds_per_mile', + }); + + // Assert controls are correct + expect(wrapper.vm._computedWatchers.outputValue.value).to.be.closeTo(30 * 60, 0.001); + }); + + it('should correctly convert to and from hh:mm:ss', async () => { + // Initialize component + const wrapper = shallowMount(UnitCalculator); + + // Change category and update input + await wrapper.setData({ category: 'time' }); + await wrapper.setData({ + inputValue: 60, + inputUnit: 'hh:mm:ss', + outputUnit: 'minutes', + }); + + // Assert controls are correct + expect(wrapper.vm._computedWatchers.outputValue.value).to.equal(1); + + // Update input + await wrapper.setData({ + inputValue: 1, + inputUnit: 'minutes', + outputUnit: 'hh:mm:ss', + }); + + // Assert controls are correct + expect(wrapper.vm._computedWatchers.outputValue.value).to.equal(60); + }); +}); diff --git a/vue.config.js b/vue.config.js @@ -1,4 +1,5 @@ module.exports = { + publicPath: process.env.NODE_ENV === 'production' ? '/running-tools/' : '/', pwa: { name: 'Running Tools', themeColor: '#ff8000', @@ -15,6 +16,7 @@ module.exports = { background_color: '#ff8000', display: 'fullscreen', lang: 'en-US', + scope: './', }, }, };