running-tools

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

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:
MREADME.md | 2++
Msrc/App.vue | 4++--
Msrc/components/SingleOutputTable.vue | 8--------
Msrc/components/TargetEditor.vue | 88++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Msrc/components/TargetSetSelector.vue | 4++--
Msrc/router/index.js | 10++++++++++
Msrc/utils/calculators.js | 41+++++++++++++++++++++++++++++++++++++++++
Msrc/utils/targets.js | 21+++++++++++++++++++++
Msrc/views/AboutPage.vue | 48+++++++++++++++++++++++++++++++++++-------------
Msrc/views/HomePage.vue | 28++++++++++++++++++----------
Msrc/views/RaceCalculator.vue | 2+-
Asrc/views/WorkoutCalculator.vue | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/e2e/cross-calculator.spec.js | 36+++++++++++++++++++++++++++++++-----
Atests/e2e/workout-calculator.spec.js | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/components/TargetEditor.spec.js | 371+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/unit/utils/calculators.spec.js | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/views/WorkoutCalculator.spec.js | 238+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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'"> + &nbsp;@&nbsp; + </span> + + <span v-if="item.type === 'distance'"> + <decimal-input v-model="item.distanceValue" aria-label="Target distance value" + :min="0" :digits="2"/> + <select v-model="item.distanceUnit" aria-label="Target distance unit"> + <option v-for="(value, key) in DISTANCE_UNITS" :key="key" :value="key"> + {{ value.name }} + </option> + </select> + </span> + + <span v-else> + <time-input v-model="item.time" label="Target duration"/> + </span> </td> <td> @@ -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, + })); +});