commit 5b8f066f78aa1d3af92041d0a025f4dd6ade39ee
parent adbca08c2b3d0ec5d085abcb63162e4165f37f1a
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date: Wed, 19 Jun 2024 14:26:07 -0700
Merge pull request #9 from ashermorgan/workout-calculator
Add workout calculator
Diffstat:
17 files changed, 1151 insertions(+), 72 deletions(-)
diff --git a/README.md b/README.md
@@ -11,6 +11,8 @@ Try it out [here](https://ashermorgan.github.io/running-tools/).
Find splits, paces, and cumulative times for the segments of a race
- [Unit Calculator](https://ashermorgan.github.io/running-tools/#/calculate/units):
Convert between different distance, time, speed, and pace units
+- [Workout Calculator](https://ashermorgan.github.io/running-tools/#/calculate/workouts):
+ Estimate target workout splits using previous race results
## Setup
Install dependencies
diff --git a/src/App.vue b/src/App.vue
@@ -55,10 +55,10 @@ h1 {
#route-content {
margin: 1em;
}
-@media only screen and (max-width: 320px) {
+@media only screen and (max-width: 450px) {
/* adjust title size to fit small devices */
h1 {
- font-size: 8vw;
+ font-size: 7vw;
}
}
</style>
diff --git a/src/components/SingleOutputTable.vue b/src/components/SingleOutputTable.vue
@@ -63,14 +63,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
-
- /**
- * The unit system to use when showing result paces
- */
- defaultUnitSystem: {
- type: String,
- default: 'metric',
- },
});
/**
diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue
@@ -22,18 +22,34 @@
<tbody>
<tr v-for="(item, index) in internalValue.targets" :key="index">
- <td v-if="item.type === 'distance'">
- <decimal-input v-model="item.distanceValue" aria-label="Target distance value"
- :min="0" :digits="2"/>
- <select v-model="item.distanceUnit" aria-label="Target distance unit">
- <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key">
- {{ value.name }}
- </option>
- </select>
- </td>
-
- <td v-else>
- <time-input v-model="item.time" label="Target duration"/>
+ <td>
+ <span v-if="setType === 'workout'">
+ <decimal-input v-model="item.splitValue" aria-label="Split distance value"
+ :min="0" :digits="2"/>
+ <select v-model="item.splitUnit" aria-label="Split distance unit">
+ <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key">
+ {{ value.name }}
+ </option>
+ </select>
+ </span>
+
+ <span v-if="setType === 'workout'">
+ @
+ </span>
+
+ <span v-if="item.type === 'distance'">
+ <decimal-input v-model="item.distanceValue" aria-label="Target distance value"
+ :min="0" :digits="2"/>
+ <select v-model="item.distanceUnit" aria-label="Target distance unit">
+ <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key">
+ {{ value.name }}
+ </option>
+ </select>
+ </span>
+
+ <span v-else>
+ <time-input v-model="item.time" label="Target duration"/>
+ </span>
</td>
<td>
@@ -104,7 +120,7 @@ const props = defineProps({
},
/**
- * The target set type ('standard' or 'split')
+ * The target set type ('standard', 'split', or 'workout')
*/
setType: {
type: String,
@@ -138,21 +154,40 @@ watch(internalValue, (newValue) => {
* Add a new distance based target
*/
function addDistanceTarget() {
- internalValue.value.targets.push({
- type: 'distance',
- distanceValue: 1,
- distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
- });
+ if (props.setType === 'workout') {
+ internalValue.value.targets.push({
+ type: 'distance',
+ distanceValue: 1,
+ distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
+ splitValue: 1,
+ splitUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
+ });
+ } else {
+ internalValue.value.targets.push({
+ type: 'distance',
+ distanceValue: 1,
+ distanceUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
+ });
+ }
}
/**
* Add a new time based target
*/
function addTimeTarget() {
- internalValue.value.targets.push({
- type: 'time',
- time: 600,
- });
+ if (props.setType === 'workout') {
+ internalValue.value.targets.push({
+ type: 'time',
+ time: 600,
+ splitValue: 1,
+ splitUnit: getDefaultDistanceUnit(props.defaultUnitSystem),
+ });
+ } else {
+ internalValue.value.targets.push({
+ type: 'time',
+ time: 600,
+ });
+ }
}
/**
@@ -169,6 +204,12 @@ function removeTarget(index) {
.target-editor th .icon {
margin-left: 0.3em;
}
+.target-editor tbody td:first-child {
+ display: flex;
+ gap: 0.2em;
+ flex-wrap: wrap;
+ align-items: center;
+}
.target-editor th:last-child, .target-editor td:last-child {
text-align: right;
}
@@ -183,9 +224,6 @@ function removeTarget(index) {
.target-editor tfoot button {
margin: 0.5em;
}
-.target-editor tfoot p {
- margin-top: 0.5em;
-}
@media only screen and (max-width: 800px) {
/* leave space for revert button on mobile devices */
.target-editor th input {
diff --git a/src/components/TargetSetSelector.vue b/src/components/TargetSetSelector.vue
@@ -54,7 +54,7 @@ defineProps({
},
/**
- * The target set type ('standard' or 'split')
+ * The target set type ('standard', 'split', or 'workout')
*/
setType: {
type: String,
@@ -131,7 +131,7 @@ function sortTargetSet() {
}
.target-set-editor-dialog {
- width: min(100% - 2em, 400px);
+ width: min(100% - 2em, 450px);
max-height: min(100% - 2em, 815px);
margin-top: 100px;
}
diff --git a/src/router/index.js b/src/router/index.js
@@ -4,6 +4,7 @@ import AboutPage from '@/views/AboutPage.vue';
import PaceCalculator from '@/views/PaceCalculator.vue';
import RaceCalculator from '@/views/RaceCalculator.vue';
import SplitCalculator from '@/views/SplitCalculator.vue';
+import WorkoutCalculator from '@/views/WorkoutCalculator.vue';
import UnitCalculator from '@/views/UnitCalculator.vue';
import NotFoundPage from '@/views/NotFoundPage.vue';
@@ -73,6 +74,15 @@ const router = createRouter({
},
},
{
+ path: '/calculate/workouts',
+ name: 'calculate-workouts',
+ component: WorkoutCalculator,
+ meta: {
+ title: 'Workout Calculator',
+ back: 'home',
+ },
+ },
+ {
path: '/:pathMatch(.*)*',
component: NotFoundPage,
},
diff --git a/src/utils/calculators.js b/src/utils/calculators.js
@@ -130,3 +130,44 @@ export function calculateRaceStats(input) {
vo2MaxPercentage: raceUtils.getVO2Percentage(input.time) * 100,
}
}
+
+/**
+ * Predict workout results from a target
+ * @param {Object} input The input race
+ * @param {Object} target The workout target
+ * @param {Object} options The race prediction options
+ * @returns {Object} The result
+ */
+export function calculateWorkoutResults(input, target, options) {
+ const d1 = convertDistance(input.distanceValue, input.distanceUnit, 'meters');
+ const t1 = input.time;
+ const d3 = convertDistance(target.splitValue, target.splitUnit, 'meters');
+ let d2, t2, t3;
+
+ // Calculate pace
+ let key = formatNumber(target.splitValue, 0, 2, false) + ' '
+ + DISTANCE_UNITS[target.splitUnit].symbol + ' @ ';
+ if (target.type === 'distance') {
+ // Convert target distance into meters
+ d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters');
+ t2 = raceUtils.predictTime(d1, input.time, d2, options.model, options.riegelExponent);
+ key += formatNumber(target.distanceValue, 0, 2, false) + ' '
+ + DISTANCE_UNITS[target.distanceUnit].symbol;
+ } else {
+ t2 = target.time;
+ d2 = raceUtils.predictDistance(t1, d1, t2, options.model,
+ options.riegelExponent);
+ key += formatDuration(target.time, 3, 2, false);
+ }
+
+ t3 = paceUtils.calculateTime(d2, t2, d3);
+
+ // Calculate time
+ return {
+ key: key,
+ value: formatDuration(t3, 3, 2, true),
+ pace: '', // Pace not used in workout calculator
+ result: 'value',
+ sort: t3,
+ }
+}
diff --git a/src/utils/targets.js b/src/utils/targets.js
@@ -87,4 +87,25 @@ export const defaultTargetSets = {
{ type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' },
],
},
+ '_workout_targets': {
+ name: 'Common Workout Targets',
+ targets: [
+ {
+ splitValue: 400, splitUnit: 'meters',
+ type: 'distance', distanceValue: 1, distanceUnit: 'miles',
+ },
+ {
+ splitValue: 800, splitUnit: 'meters',
+ type: 'distance', distanceValue: 5, distanceUnit: 'kilometers',
+ },
+ {
+ splitValue: 1600, splitUnit: 'meters',
+ type: 'time', time: 3600,
+ },
+ {
+ splitValue: 2, splitUnit: 'miles',
+ type: 'time', time: 7200,
+ },
+ ],
+ },
};
diff --git a/src/views/AboutPage.vue b/src/views/AboutPage.vue
@@ -19,7 +19,7 @@
</p>
<h2>The Calculators</h2>
- <p>Running Tools contains four calculators:</p>
+ <p>Running Tools contains five calculators:</p>
<h3>Pace Calculator</h3>
<p>
@@ -44,10 +44,12 @@
equivalent race results.
The selected target set controls which distances and/or times the calculator predicts race
results for.
+ Extra output statistics for the input race result are also available under the Race Statistics
+ section.
</p>
<p>
- The Advanced section of the Race Calculator includes extra output statistics for the input
- race result and the option to switch between the five supported race prediction models:
+ The Advanced Options section includes the option to switch between the five supported race
+ prediction models:
</p>
<ul>
<li>The Purdy Points Model</li>
@@ -85,7 +87,7 @@
<ul class="questions">
<li>How fast would I finish a 1600m if I ran the 400m laps in 90s, 85s, 80s, and 75s? (5:30)</li>
<li>If I finished a 5K in 20:00 and ran the first 2 miles in 13:00, how fast was the last ~1.1
- miles? (6:19 per mile pace)</li>
+ miles? (6:19 / mi pace)</li>
</ul>
<h3>Unit Calculator</h3>
@@ -98,13 +100,37 @@
</p>
<ul class="questions">
<li>How many miles is a 5K? (3.107 miles)</li>
- <li>What is 10 mph in time per mile? (6:00 per mile)</li>
+ <li>What is 10 mph in time per mile? (6:00 / mi)</li>
<li>What is 123.4 minutes in hh:mm:ss? (02:03:24)</li>
</ul>
+ <h3>Workout Calculator</h3>
+ <p>
+ The <router-link :to="{ name: 'calculate-workouts' }">Workout Calculator</router-link> takes a
+ distance and duration as input and shows intermediate splits for other equivalent race
+ results.
+ The selected target set controls which race distances and/or times the calculator calculates
+ outputs for and the distances of the splits that are shown for these races.
+ The Advanced Options section includes the option to switch between the same five prediction
+ models that are available in the Race Calculator.
+ </p>
+ <p>
+ The Workout Calculator is useful for answering questions like:
+ </p>
+ <ul class="questions">
+ <li>If I raced a 5K in 20:00, how fast should I run 400m intervals at mile pace? (about 1:27)</li>
+ <li>If I raced a mile in 5:00, what is my "threshold" (~1 hr race) pace? (about 5:50 / mi)</li>
+ </ul>
+ <p>
+ <strong>Note:</strong> Results are just estimated race splits that are helpful for estimating
+ target workout splits.
+ As with the Race Calculator, splits are most accurate for similar distances and assume equal
+ fitness.
+ </p>
+
<h2>Target Sets</h2>
<p>
- A target set is a collection of distances and/or times that the Pace, Race, or Split
+ A target set is a collection of distances and/or times that the Pace, Race, Split, or Workout
Calculators will calculate results for.
These calculators will output a duration for each distance target and a distance for each time
target.
@@ -113,7 +139,8 @@
calculator.
</p>
<p>
- <strong>Note:</strong> The split calculator only supports distance targets.
+ <strong>Note:</strong> The split calculator only supports distance targets. The workout
+ calculator also includes a split distance field for each target.
</p>
</div>
</template>
@@ -143,7 +170,7 @@ p, blockquote, ul {
}
li {
margin-bottom: 0.2em;
- margin-left: 3em;
+ margin-left: 1.5em;
}
p {
line-height: 1.3;
@@ -163,9 +190,4 @@ p {
filter: invert(1);
}
}
-@media only screen and (max-width: 800px) {
- li {
- margin-left: 1.5em;
- }
-}
</style>
diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue
@@ -24,6 +24,12 @@
Unit Calculator
</button>
</router-link>
+ <router-link :to="{ name: 'calculate-workouts' }" v-slot="{ navigate }" custom>
+ <button @click="navigate">
+ Workout Calculator
+ </button>
+ </router-link>
+ <div class="card"></div>
</div>
<p class="about-link">
<router-link :to="{ name: 'about' }">
@@ -41,28 +47,30 @@
}
.description {
font-size: 1.5em;
- margin-bottom: 1em;
}
.calculators {
display: flex;
- flex-direction: row;
+ flex-wrap: wrap;
+ gap: 0.5em;
+ justify-content: center;
+
+ max-width: 39em;
+ margin: 1em auto;
+}
+.calculators > * {
+ width: 12em;
}
.calculators button {
- flex-grow: 1;
font-size: 1em;
padding: 0.5em;
- margin: 0em 0.3em;
-}
-.about-link {
- margin-top: 1em;
}
-@media only screen and (max-width: 600px) {
+@media only screen and (max-width: 500px) {
.calculators {
- flex-direction: column;
+ gap: 0.75em;
}
.calculators button {
- margin: 0.3em 0em;
padding: 0.75em 0.5em;
+ width: 100%;
}
}
</style>
diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue
@@ -42,7 +42,7 @@
</details>
<h2>Equivalent Race Results</h2>
- <single-output-table class="output" :default-unit-system="defaultUnitSystem" show-pace
+ <single-output-table class="output" show-pace
:calculate-result="x => calculateRaceResults(input, x, options, defaultUnitSystem)"
:targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/>
</div>
diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue
@@ -0,0 +1,83 @@
+<template>
+ <div class="calculator">
+ <h2>Input Race Result</h2>
+ <div class="input">
+ <pace-input v-model="input" label="Input race"/>
+ </div>
+
+ <details>
+ <summary>
+ <h2>Advanced Options</h2>
+ </summary>
+ <div>
+ Default units:
+ <select v-model="defaultUnitSystem" aria-label="Default units">
+ <option value="imperial">Miles</option>
+ <option value="metric">Kilometers</option>
+ </select>
+ </div>
+ <div>
+ Target Set:
+ <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="workout"
+ v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/>
+ </div>
+ <race-options v-model="options"/>
+ </details>
+
+ <h2>Workout Splits</h2>
+ <single-output-table class="output"
+ :calculate-result="x => calculateWorkoutResults(input, x, options)"
+ :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/>
+ </div>
+</template>
+
+<script setup>
+import { calculateWorkoutResults } from '@/utils/calculators';
+import { defaultTargetSets } from '@/utils/targets';
+import { detectDefaultUnitSystem } from '@/utils/units';
+
+import PaceInput from '@/components/PaceInput.vue';
+import RaceOptions from '@/components/RaceOptions.vue';
+import SingleOutputTable from '@/components/SingleOutputTable.vue';
+import TargetSetSelector from '@/components/TargetSetSelector.vue';
+
+import useStorage from '@/composables/useStorage';
+
+/**
+ * The input race
+ */
+const input = useStorage('workout-calculator-input', {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+});
+
+/**
+ * The default unit system
+ */
+const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem());
+
+/**
+ * The race prediction options
+ */
+const options = useStorage('workout-calculator-options', {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+});
+
+/**
+ * The current selected target set
+ */
+const selectedTargetSet = useStorage('workout-calculator-target-set', '_workout_targets');
+
+/**
+ * The target sets
+ */
+let targetSets = useStorage('workout-calculator-target-sets', {
+ _workout_targets: defaultTargetSets._workout_targets
+});
+</script>
+
+<style scoped>
+@import '@/assets/target-calculator.css';
+</style>
diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js
@@ -12,11 +12,8 @@ test('Save and update state when navigating between calculators', async ({ page
await page.getByLabel('Input duration minutes').fill('15');
await page.getByLabel('Input duration seconds').fill('30');
- // Change default units (should update on other calculators too)
- await page.getByText('Advanced Options').click();
- await page.getByLabel('Default units').selectOption('Kilometers');
-
// Create custom target set
+ await page.getByText('Advanced Options').click();
await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.');
await expect(page.getByRole('row')).toHaveCount(2);
@@ -31,6 +28,7 @@ test('Save and update state when navigating between calculators', async ({ page
await page.getByRole('button', { name: 'Add distance target' }).click();
await page.getByLabel('Target distance value').nth(1).fill('800');
await page.getByLabel('Target distance unit').nth(1).selectOption('Meters');
+ await page.getByRole('button', { name: 'Add time target' }).click();
await page.getByRole('button', { name: 'Close' }).click();
// Go to race calculator
@@ -79,6 +77,24 @@ test('Save and update state when navigating between calculators', async ({ page
await page.getByLabel('Input value').fill('10');
await page.getByLabel('Output units').selectOption('Time per Mile');
+ // Go to workout calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Workout Calculator' }).click();
+
+ // Enter input race (1 mi in 5:01)
+ await page.getByLabel('Input race distance value').fill('1');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
+ await page.getByLabel('Input race duration hours').fill('0');
+ await page.getByLabel('Input race duration minutes').fill('5');
+ await page.getByLabel('Input race duration seconds').fill('1');
+
+ // Change prediction model
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Prediction model').selectOption('V̇O₂ Max Model');
+
+ // Change default units (should update on other calculators too)
+ await page.getByLabel('Default units').selectOption('Kilometers');
+
// Return to pace calculator
await page.getByRole('link', { name: 'Back' }).click();
await page.getByRole('button', { name: 'Pace Calculator' }).click();
@@ -86,7 +102,8 @@ test('Save and update state when navigating between calculators', async ({ page
// Assert paces are correct (input pace not reset)
await expect(page.getByRole('row').nth(1)).toHaveText('0.4 km' + '1:55.57');
await expect(page.getByRole('row').nth(2)).toHaveText('800 m' + '3:51.15');
- await expect(page.getByRole('row')).toHaveCount(3);
+ await expect(page.getByRole('row').nth(3)).toHaveText('2.08 km' + '10:00');
+ await expect(page.getByRole('row')).toHaveCount(4);
// Return to race calculator
await page.getByRole('link', { name: 'Back' }).click();
@@ -116,4 +133,13 @@ test('Save and update state when navigating between calculators', async ({ page
// Assert result is correct (state not reset)
await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366');
+
+ // Return to workout calculator
+ await page.getByRole('link', { name: 'Back' }).click();
+ await page.getByRole('button', { name: 'Workout Calculator' }).click();
+
+ // Assert workout splits are correct (input race and prediction model not reset)
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:14.81');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:53.58');
+ await expect(page.getByRole('row')).toHaveCount(5);
});
diff --git a/tests/e2e/workout-calculator.spec.js b/tests/e2e/workout-calculator.spec.js
@@ -0,0 +1,186 @@
+import { test, expect } from '@playwright/test';
+
+test('Basic usage', async ({ page }) => {
+ // Go to workout calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Workout Calculator' }).click();
+ await expect(page).toHaveTitle('Workout Calculator - Running Tools');
+
+ // Enter input race (2 mi in 10:30)
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
+ await page.getByLabel('Input race duration hours').fill('0');
+ await page.getByLabel('Input race duration minutes').fill('10');
+ await page.getByLabel('Input race duration seconds').fill('30');
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:13.45');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:45.44');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Change prediction model
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Prediction model').selectOption('Riegel\'s Model');
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:15.10');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:45.64');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Change Riegel exponent
+ await page.getByLabel('Riegel Exponent').fill('1.12');
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:12.04');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '6:17.47');
+ await expect(page.getByRole('row')).toHaveCount(5);
+});
+
+test('Customize target sets', async ({ page }) => {
+ // Go to workout calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Workout Calculator' }).click();
+
+ // Enter input race (2 mi in 10:30)
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
+ await page.getByLabel('Input race duration hours').fill('0');
+ await page.getByLabel('Input race duration minutes').fill('10');
+ await page.getByLabel('Input race duration seconds').fill('30');
+
+ // Edit default target set
+ await page.getByText('Advanced Options').click();
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await page.getByLabel('Target set label').fill('Less-common Workout Targets');
+ await page.getByLabel('Split distance value').nth(0).fill('401');
+ await page.getByLabel('Target distance value').nth(0).fill('2');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Split distance value').last().fill('1');
+ await page.getByLabel('Split distance unit').last().selectOption('Miles');
+ await page.getByLabel('Target distance value').last().fill('10');
+ await page.getByLabel('Target distance unit').last().selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add time target' }).click();
+ await page.getByLabel('Split distance value').last().fill('600');
+ await page.getByLabel('Split distance unit').last().selectOption('Meters');
+ await page.getByLabel('Target duration minutes').last().fill('19');
+ await page.getByLabel('Target duration seconds').last().fill('0');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49');
+ await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:01.73');
+ await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '5:36.97');
+ await expect(page.getByRole('row')).toHaveCount(7);
+
+ // Create custom target set
+ await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
+ await expect(page.getByRole('row').nth(1)).toHaveText('There aren\'t any targets in this set yet.');
+ await expect(page.getByRole('row')).toHaveCount(2);
+
+ // Edit new target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('New target set');
+ await page.getByLabel('Target set label').fill('Workout Target Set #2');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Split distance value').last().fill('800');
+ await page.getByLabel('Split distance unit').last().selectOption('Meters');
+ await page.getByLabel('Target distance value').last().fill('5');
+ await page.getByLabel('Target distance unit').last().selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Split distance value').last().fill('1600');
+ await page.getByLabel('Split distance unit').last().selectOption('Meters');
+ await page.getByLabel('Target distance value').last().fill('10');
+ await page.getByLabel('Target distance unit').last().selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:41.21');
+ await expect(page.getByRole('row').nth(2)).toHaveText('1600 m @ 10 km' + '5:35.01');
+ await expect(page.getByRole('row')).toHaveCount(3);
+
+ // Switch target set
+ await page.getByLabel('Selected target set').selectOption('Less-common Workout Targets');
+
+ // Assert workout splits are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49');
+ await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:01.73');
+ await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '5:36.97');
+ await expect(page.getByRole('row')).toHaveCount(7);
+
+ // Delete custom target set
+ await page.getByLabel('Selected target set').selectOption('Workout Target Set #2');
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Workout Target Set #2');
+ await page.getByRole('button', { name: 'Delete target set' }).click();
+
+ // Switch to default target set
+ await page.getByLabel('Selected target set').selectOption('Less-common Workout Targets');
+
+ // Assert workout splits are correct (back to default target set)
+ await expect(page.getByRole('row').nth(1)).toHaveText('401 m @ 2 mi' + '1:18.49');
+ await expect(page.getByRole('row').nth(2)).toHaveText('600 m @ 19:00' + '2:01.73');
+ await expect(page.getByRole('row').nth(4)).toHaveText('1 mi @ 10 km' + '5:36.97');
+ await expect(page.getByRole('row')).toHaveCount(7);
+
+ // Revert target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Less-common Workout Targets');
+ await page.getByRole('button', { name: 'Revert target set' }).click();
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Assert paces are correct
+ await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:13.45');
+ await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:45.44');
+ await expect(page.getByRole('row')).toHaveCount(5);
+
+ // Assert title was reset
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('Common Workout Targets');
+});
+
+test('Save settings across page reloads', async ({ page }) => {
+ // Go to workout calculator
+ await page.goto('/');
+ await page.getByRole('button', { name: 'Workout Calculator' }).click();
+
+ // Enter input race (2 mi in 10:30)
+ await page.getByLabel('Input race distance value').fill('2');
+ await page.getByLabel('Input race distance unit').selectOption('Miles');
+ await page.getByLabel('Input race duration hours').fill('0');
+ await page.getByLabel('Input race duration minutes').fill('10');
+ await page.getByLabel('Input race duration seconds').fill('30');
+
+ // Create custom target set
+ await page.getByText('Advanced Options').click();
+ await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]');
+
+ // Edit new target set
+ await page.getByRole('button', { name: 'Edit target set' }).click();
+ await expect(page.getByLabel('Target set label')).toHaveValue('New target set');
+ await page.getByLabel('Target set label').fill('Workout Target Set #2');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Split distance value').last().fill('800');
+ await page.getByLabel('Split distance unit').last().selectOption('Meters');
+ await page.getByLabel('Target distance value').last().fill('5');
+ await page.getByLabel('Target distance unit').last().selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Add distance target' }).click();
+ await page.getByLabel('Split distance value').last().fill('1600');
+ await page.getByLabel('Split distance unit').last().selectOption('Meters');
+ await page.getByLabel('Target distance value').last().fill('10');
+ await page.getByLabel('Target distance unit').last().selectOption('Kilometers');
+ await page.getByRole('button', { name: 'Close' }).click();
+
+ // Change prediction model
+ await page.getByLabel('Prediction model').selectOption('Riegel\'s Model');
+
+ // Change Riegel exponent
+ await page.getByLabel('Riegel Exponent').fill('1.12');
+
+ // Reload page
+ await page.reload();
+
+ // Assert workout splits are correct (custom targets and model settings not reset)
+ await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:45.08');
+ await expect(page.getByRole('row').nth(2)).toHaveText('1600 m @ 10 km' + '5:58.80');
+ await expect(page.getByRole('row')).toHaveCount(3);
+});
diff --git a/tests/unit/components/TargetEditor.spec.js b/tests/unit/components/TargetEditor.spec.js
@@ -2,7 +2,7 @@ import { test, expect } from 'vitest';
import { shallowMount } from '@vue/test-utils';
import TargetEditor from '@/components/TargetEditor.vue';
-test('should correctly render target set', async () => {
+test('should correctly render standard target set', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
@@ -14,6 +14,7 @@ test('should correctly render target set', async () => {
{ time: 600, type: 'time' },
],
},
+ setType: 'standard',
},
});
@@ -28,6 +29,76 @@ test('should correctly render target set', async () => {
expect(rows.length).to.equal(3);
});
+test('should correctly render split target set', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'kilometers', distanceValue: 1.61, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 3.11, type: 'distance' },
+ ],
+ },
+ setType: 'split',
+ },
+ });
+
+ // Assert target set correctly rendered
+ expect(wrapper.find('input').element.value).to.equal('My target set');
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.61);
+ expect(rows[0].find('select').element.value).to.equal('kilometers');
+ expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3.11);
+ expect(rows[1].find('select').element.value).to.equal('miles');
+ expect(rows.length).to.equal(2);
+});
+
+test('should correctly render workout target set', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ distanceUnit: 'kilometers', distanceValue: 5,
+ splitUnit: 'miles', splitValue: 1,
+ type: 'distance'
+ },
+ ],
+ },
+ setType: 'workout',
+ },
+ });
+
+ // Assert target set correctly rendered
+ expect(wrapper.find('input').element.value).to.equal('My target set');
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(400);
+ expect(rows[0].findAll('select')[0].element.value).to.equal('meters');
+ expect(rows[0].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(2);
+ expect(rows[0].findAll('select')[1].element.value).to.equal('miles');
+ expect(rows[1].findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(2);
+ expect(rows[1].find('select').element.value).to.equal('kilometers');
+ expect(rows[1].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(6000);
+ expect(rows[2].findAllComponents({ name: 'decimal-input' })[0].vm.modelValue).to.equal(1);
+ expect(rows[2].findAll('select')[0].element.value).to.equal('miles');
+ expect(rows[2].findAllComponents({ name: 'decimal-input' })[1].vm.modelValue).to.equal(5);
+ expect(rows[2].findAll('select')[1].element.value).to.equal('kilometers');
+ expect(rows.length).to.equal(3);
+});
+
test('revert button should emit revert event', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor);
@@ -65,7 +136,7 @@ test('close button should emit close event', async () => {
expect(wrapper.emitted().close.length).to.equal(1);
});
-test('add distance target button should correctly add imperial distance target', async () => {
+test('add distance target button should correctly add standard imperial distance target', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
@@ -76,6 +147,7 @@ test('add distance target button should correctly add imperial distance target',
{ time: 0, type: 'time' },
],
},
+ setType: 'standard',
defaultUnitSystem: 'imperial'
},
});
@@ -96,7 +168,7 @@ test('add distance target button should correctly add imperial distance target',
]);
});
-test('add distance target button should correctly add metric distance target', async () => {
+test('add distance target button should correctly add standard metric distance target', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
@@ -107,6 +179,7 @@ test('add distance target button should correctly add metric distance target', a
{ time: 0, type: 'time' },
],
},
+ setType: 'standard',
defaultUnitSystem: 'metric'
},
});
@@ -127,7 +200,171 @@ test('add distance target button should correctly add metric distance target', a
]);
});
-test('add time target button should correctly add time target', async () => {
+test('add distance target button should correctly add split imperial distance target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ ],
+ },
+ setType: 'split',
+ defaultUnitSystem: 'imperial'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add distance target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { distanceUnit: 'miles', distanceValue: 1, type: 'distance'},
+ ],
+ }],
+ ]);
+});
+
+test('add distance target button should correctly add split metric distance target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ ],
+ },
+ setType: 'split',
+ defaultUnitSystem: 'metric'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add distance target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ { distanceUnit: 'miles', distanceValue: 0, type: 'distance' },
+ { distanceUnit: 'kilometers', distanceValue: 1, type: 'distance'},
+ ],
+ }],
+ ]);
+});
+
+test('add distance target button should correctly add workout imperial distance target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ ],
+ },
+ setType: 'workout',
+ defaultUnitSystem: 'imperial'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add distance target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ distanceUnit: 'miles', distanceValue: 1,
+ splitUnit: 'miles', splitValue: 1,
+ type: 'distance'
+ },
+ ],
+ }],
+ ]);
+});
+
+test('add distance target button should correctly add workout metric distance target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ ],
+ },
+ setType: 'workout',
+ defaultUnitSystem: 'metric'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add distance target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ distanceUnit: 'kilometers', distanceValue: 1,
+ splitUnit: 'kilometers', splitValue: 1,
+ type: 'distance'
+ },
+ ],
+ }],
+ ]);
+});
+
+test('add time target button should correctly add standard time target', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
@@ -138,6 +375,7 @@ test('add time target button should correctly add time target', async () => {
{ time: 0, type: 'time' },
],
},
+ setType: 'standard',
},
});
@@ -175,7 +413,111 @@ test('add time target button should be hidden for split target sets', async () =
expect(wrapper.findAll('button[title="Add time target"]')).toHaveLength(0);
});
-test('Should emit input event when targets are updated', async () => {
+test('add time target button should correctly add workout imperial time target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ ],
+ },
+ setType: 'workout',
+ defaultUnitSystem: 'imperial'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add time target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ time: 600,
+ splitUnit: 'miles', splitValue: 1,
+ type: 'time'
+ },
+ ],
+ }],
+ ]);
+});
+
+test('add time target button should correctly add workout metric time target', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ ],
+ },
+ setType: 'workout',
+ defaultUnitSystem: 'metric'
+ },
+ });
+
+ // Add distance target
+ await wrapper.find('button[title="Add time target"]').trigger('click');
+
+ // Assert input event was emitted
+ expect(wrapper.emitted()['update:modelValue']).to.deep.equal([
+ [{
+ name: 'My target set',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ time: 600,
+ splitUnit: 'kilometers', splitValue: 1,
+ type: 'time'
+ },
+ ],
+ }],
+ ]);
+});
+
+test('should emit input event when targets are updated', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
@@ -204,7 +546,7 @@ test('Should emit input event when targets are updated', async () => {
]);
});
-test('Should emit input event when target set name is updated', async () => {
+test('should emit input event when target set name is updated', async () => {
// Initialize component
const wrapper = shallowMount(TargetEditor, {
propsData: {
@@ -262,3 +604,20 @@ test('removeTarget button should correctly remove target', async () => {
}],
]);
});
+
+test('should display message when target set is empty', async () => {
+ // Initialize component
+ const wrapper = shallowMount(TargetEditor, {
+ propsData: {
+ modelValue: {
+ name: 'My target set',
+ targets: [],
+ },
+ },
+ });
+
+ // Assert message correctly rendered
+ const rows = wrapper.findAll('tbody tr');
+ expect(rows[0].text()).to.equal('There aren\'t any targets in this set yet');
+ expect(rows.length).to.equal(1);
+});
diff --git a/tests/unit/utils/calculators.spec.js b/tests/unit/utils/calculators.spec.js
@@ -146,3 +146,56 @@ test('should correctly calculate race statistics', () => {
expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1);
expect(results.vo2Max).to.be.closeTo(49.8, 0.1);
});
+
+test('should correctly calculate distance-based workouts according to race options', () => {
+ const input = {
+ distanceValue: 2,
+ distanceUnit: 'miles',
+ time: 630,
+ };
+ const target = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers', // 5k split is ~17:11.77
+ splitValue: 1000,
+ splitUnit: 'meters',
+ type: 'distance',
+ };
+ const options = {
+ model: 'RiegelModel',
+ riegelExponent: 1.12,
+ }
+
+ const result = calculatorUtils.calculateWorkoutResults(input, target, options);
+
+ expect(result.key).to.equal('1000 m @ 5 km');
+ expect(result.value).to.equal('3:26.35');
+ expect(result.pace).to.equal('');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(206.35, 0.01);
+});
+
+test('should correctly calculate time-based workouts', () => {
+ const input = {
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ };
+ const target = {
+ time: 2495, // ~10k split is 41:35
+ splitValue: 1,
+ splitUnit: 'miles',
+ type: 'time',
+ };
+ const options = {
+ model: 'AverageModel',
+ riegelExponent: 1.06,
+ }
+
+ const result = calculatorUtils.calculateWorkoutResults(input, target, options);
+
+ expect(result.key).to.equal('1 mi @ 41:35');
+ expect(result.value).to.equal('6:41.50');
+ expect(result.pace).to.equal('');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(401.50, 0.01);
+});
diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js
@@ -0,0 +1,238 @@
+import { beforeEach, test, expect } from 'vitest';
+import { shallowMount } from '@vue/test-utils';
+import WorkoutCalculator from '@/views/WorkoutCalculator.vue';
+import { defaultTargetSets } from '@/utils/targets';
+
+beforeEach(() => {
+ localStorage.clear();
+})
+
+test('should correctly predict workout splits', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
+
+ // Calculate result
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
+ const result = calculateResult({
+ splitValue: 1, splitUnit: 'kilometers',
+ type: 'distance', distanceValue: 10, distanceUnit: 'kilometers',
+ });
+
+ // Assert result is correct
+ expect(result.key).to.equal('1 km @ 10 km');
+ expect(result.value).to.equal('4:09.48');
+ expect(result.result).to.equal('value');
+ expect(result.sort).to.be.closeTo(249.48, 0.01);
+});
+
+test('should correctly handle null target set', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Switch to invalid target set
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('does_not_exist', 'selectedTargetSet');
+
+ // Assert empty array passed to SingleOutputTable component
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets).to.deep.equal([]);
+
+ // Switch to valid target set
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('_workout_targets', 'selectedTargetSet');
+
+ // Assert valid targets passed to SingleOutputTable component
+ const workoutTargets = defaultTargetSets._workout_targets.targets;
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets)
+ .to.deep.equal(workoutTargets);
+});
+
+test('should correctly calculate results according to advanced model options', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 5,
+ distanceUnit: 'kilometers',
+ time: 1200,
+ });
+
+ // Update model and Riegel Exponent
+ await wrapper.findComponent({ name: 'RaceOptions' }).setValue({
+ model: 'RiegelModel',
+ riegelExponent: 1.10,
+ });
+
+ // Calculate result
+ const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult;
+ let result = calculateResult({
+ splitValue: 1, splitUnit: 'kilometers',
+ type: 'distance', distanceValue: 10, distanceUnit: 'kilometers',
+ });
+
+ // Assert result is correct
+ expect(result.key).to.equal('1 km @ 10 km');
+ expect(result.value).to.equal('4:17.23');
+});
+
+test('should load input race from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.workout-calculator-input', JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
+
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Assert data loaded
+ expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
+});
+
+test('should save input race to localStorage', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Enter input race data
+ await wrapper.findComponent({ name: 'pace-input' }).setValue({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ });
+
+ // Assert data saved to localStorage
+ expect(localStorage.getItem('running-tools.workout-calculator-input')).to.equal(JSON.stringify({
+ distanceValue: 1,
+ distanceUnit: 'miles',
+ time: 600,
+ }));
+});
+
+test('should load selected target set from localStorage', async () => {
+ // Initialize localStorage
+ const targetSet2 = {
+ name: 'Workout targets #2',
+ targets: [
+ {
+ distanceUnit: 'miles', distanceValue: 2,
+ splitUnit: 'meters', splitValue: 400,
+ type: 'distance',
+ },
+ {
+ time: 6000,
+ splitUnit: 'kilometers', splitValue: 2,
+ type: 'time',
+ },
+ {
+ distanceUnit: 'kilometers', distanceValue: 5,
+ splitUnit: 'miles', splitValue: 1,
+ type: 'distance'
+ },
+ ],
+ };
+ localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({
+ '_workout_targets': {
+ name: 'Workout targets #1',
+ targets: [
+ {
+ splitValue: 400, splitUnit: 'meters',
+ type: 'distance', distanceValue: 1, distanceUnit: 'miles',
+ },
+ {
+ splitValue: 800, splitUnit: 'meters',
+ type: 'distance', distanceValue: 5, distanceUnit: 'kilometers',
+ },
+ {
+ splitValue: 1600, splitUnit: 'meters',
+ type: 'time', time: 3600,
+ },
+ {
+ splitValue: 2, splitUnit: 'miles',
+ type: 'time', time: 7200,
+ },
+ ],
+ },
+ 'B': targetSet2,
+ }));
+ localStorage.setItem('running-tools.workout-calculator-target-set', '"B"');
+
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Assert selection is loaded
+ expect(wrapper.findComponent({ name: 'target-set-selector' }).vm.selectedTargetSet)
+ .to.equal('B');
+ expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets)
+ .to.deep.equal(targetSet2.targets);
+});
+
+test('should save selected target set to localStorage when modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Select a new target set
+ await wrapper.findComponent({ name: 'target-set-selector' })
+ .setValue('B', 'selectedTargetSet');
+
+ // New selected target set should be saved to localStorage
+ expect(localStorage.getItem('running-tools.workout-calculator-target-set'))
+ .to.equal('"B"');
+});
+
+test('should save default units setting to localStorage when modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Change default units
+ await wrapper.find('select[aria-label="Default units"]').setValue('metric');
+ await wrapper.find('select[aria-label="Default units"]').setValue('imperial');
+
+ // New default units should be saved to localStorage
+ expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"');
+});
+
+test('should load advanced model options from localStorage', async () => {
+ // Initialize localStorage
+ localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ }));
+
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Assert data loaded
+ expect(wrapper.findComponent({ name: 'RaceOptions' }).vm.modelValue).to.deep.equal({
+ model: 'PurdyPointsModel',
+ riegelExponent: 1.2,
+ });
+});
+
+test('should save advanced model options to localStorage when modified', async () => {
+ // Initialize component
+ const wrapper = shallowMount(WorkoutCalculator);
+
+ // Update advanced model options
+ await wrapper.findComponent({ name: 'RaceOptions' }).setValue({
+ model: 'CameronModel',
+ riegelExponent: 1.30,
+ });
+
+ // Assert data saved to localStorage
+ expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({
+ model: 'CameronModel',
+ riegelExponent: 1.3,
+ }));
+});