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:
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: './',
},
},
};