running-tools

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

commit a92e735b3d3b9b181371ab9fcc66aa4aff7603e6
parent f56663164536e45c17c4fe3ecde74adc014cd3d5
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat, 31 May 2025 13:07:33 -0700

Merge branch custom-workout-target-names into dev

Add support for custom workout target names.

Diffstat:
Msrc/components/TargetEditor.vue | 18++++++++++++++++--
Msrc/components/TargetSetSelector.vue | 11++++++++++-
Msrc/composables/useStorage.js | 12+++---------
Msrc/main.js | 14++++++++------
Msrc/utils/calculators.js | 25+++++++++++--------------
Asrc/utils/storage.js | 36++++++++++++++++++++++++++++++++++++
Msrc/utils/targets.js | 20+++++++++++++++++++-
Msrc/views/BatchCalculator.vue | 11++++++++++-
Msrc/views/WorkoutCalculator.vue | 11++++++++++-
Mtests/e2e/batch-calculator.spec.js | 6++++--
Mtests/e2e/cross-calculator.spec.js | 4+++-
Mtests/e2e/workout-calculator.spec.js | 9+++++++--
Mtests/unit/components/TargetEditor.spec.js | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/unit/components/TargetSetSelector.spec.js | 17+++++++++++++++++
Mtests/unit/utils/calculators.spec.js | 424++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Atests/unit/utils/storage.spec.js | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/utils/targets.spec.js | 38++++++++++++++++++++++++++++++++++++++
Mtests/unit/views/WorkoutCalculator.spec.js | 1+
18 files changed, 586 insertions(+), 226 deletions(-)

diff --git a/src/components/TargetEditor.vue b/src/components/TargetEditor.vue @@ -23,6 +23,11 @@ <tbody> <tr v-for="(item, index) in internalValue.targets" :key="index"> <td> + <span v-if="setType === 'workout' && customWorkoutNames"> + <input v-model="item.customName" :placeholder="workoutTargetToString(item)" + aria-label="Custom target name"/>: + </span> + <span v-if="setType === 'workout'"> <decimal-input v-model="item.splitValue" aria-label="Split distance value" :min="0" :digits="2"/> @@ -86,6 +91,7 @@ import { watch, ref } from 'vue'; import VueFeather from 'vue-feather'; +import { workoutTargetToString } from '@/utils/targets'; import { DISTANCE_UNITS, getDefaultDistanceUnit } from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; @@ -104,9 +110,9 @@ const model = defineModel({ const props = defineProps({ /** - * Whether the target set is a custom or default set + * Whether to allow custom names for workout targets */ - isCustomSet: { + customWorkoutNames: { type: Boolean, default: false, }, @@ -120,6 +126,14 @@ const props = defineProps({ }, /** + * Whether the target set is a custom or default set + */ + isCustomSet: { + type: Boolean, + default: false, + }, + + /** * The target set type ('standard', 'split', or 'workout') */ setType: { diff --git a/src/components/TargetSetSelector.vue b/src/components/TargetSetSelector.vue @@ -13,7 +13,8 @@ <dialog ref="dialogElement" class="target-set-editor-dialog" aria-label="Edit target set"> <target-editor @close="sortTargetSet(); dialogElement.close()" - @revert="revertTargetSet" :default-unit-system="defaultUnitSystem" :setType="setType" + @revert="revertTargetSet" :customWorkoutNames="customWorkoutNames" + :default-unit-system="defaultUnitSystem" :setType="setType" v-model="targetSets[internalValue]" :isCustomSet="!internalValue.startsWith('_')"/> </dialog> </span> @@ -46,6 +47,14 @@ const targetSets = defineModel('targetSets', { defineProps({ /** + * Whether to allow custom names for workout targets + */ + customWorkoutNames: { + type: Boolean, + default: false, + }, + + /** * The unit system to use when creating distance targets */ defaultUnitSystem: { diff --git a/src/composables/useStorage.js b/src/composables/useStorage.js @@ -1,7 +1,6 @@ import { ref, onActivated, watchEffect } from 'vue'; -// The global localStorage prefix -const prefix = 'running-tools'; +import * as storage from '@/utils/storage'; /* * Create a reactive value that is synced with a localStorage item @@ -14,12 +13,7 @@ export default function useStorage(key, defaultValue) { // (Re)load value from localStorage function updateValue() { - let parsedValue; - try { - parsedValue = JSON.parse(localStorage.getItem(`${prefix}.${key}`)); - } catch { - parsedValue = null; - } + let parsedValue = storage.get(key); if (parsedValue !== null) value.value = parsedValue; } updateValue(); @@ -28,7 +22,7 @@ export default function useStorage(key, defaultValue) { // Save value to localStorage when modified watchEffect(() => { if (typeof localStorage !== 'undefined') { - localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value.value)); + storage.set(key, value.value); } }) diff --git a/src/main.js b/src/main.js @@ -1,11 +1,13 @@ -import './assets/global.css'; - import { createApp } from 'vue'; -import App from './App.vue'; -import router from './router'; -const app = createApp(App); +import App from '@/App.vue'; +import router from '@/router'; +import * as storage from '@/utils/storage'; -app.use(router); +import '@/assets/global.css'; +storage.migrate(); + +const app = createApp(App); +app.use(router); app.mount('#app'); diff --git a/src/utils/calculators.js b/src/utils/calculators.js @@ -1,6 +1,7 @@ import { formatDuration, formatNumber } from '@/utils/format'; import * as paceUtils from '@/utils/paces'; import * as raceUtils from '@/utils/races'; +import { workoutTargetToString } from '@/utils/targets'; import { DISTANCE_UNITS, convertDistance, getDefaultDistanceUnit } from '@/utils/units'; /** @@ -140,39 +141,35 @@ export function calculateRaceStats(input) { * 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 + * @param {Object} options The workout options * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations * @returns {Object} The result */ export function calculateWorkoutResults(input, target, options, preciseDurations = true) { + // Initialize distance and time variables 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; + // Calculate result if (target.type === 'distance') { // Convert target distance into meters d2 = convertDistance(target.distanceValue, target.distanceUnit, 'meters'); + + // Get workout split prediction t2 = raceUtils.predictTime(d1, input.time, d2, options.model, options.riegelExponent); - if (target.distanceValue != target.splitValue || target.distanceUnit != target.splitUnit) { - 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); - } + // Get workout split prediction + d2 = raceUtils.predictDistance(t1, d1, t2, options.model, options.riegelExponent); + } t3 = paceUtils.calculateTime(d2, t2, d3); - // Calculate time + // Return result return { - key: key, + key: (options.customTargetNames && target.customName) || workoutTargetToString(target), value: formatDuration(t3, 3, preciseDurations ? 2 : 0, true), pace: '', // Pace not used in workout calculator result: 'value', diff --git a/src/utils/storage.js b/src/utils/storage.js @@ -0,0 +1,36 @@ +// The global localStorage prefix +const prefix = 'running-tools'; + +/** + * Read an object from a localStorage item + * @param {String} key The localStorage item's key + * @returns {Object} The object + */ +export function get(key) { + try { + return JSON.parse(localStorage.getItem(`${prefix}.${key}`)); + } catch { + return null; + } +} + +/** + * Write an object to a localStorage item + * @param {String} key The localStorage item's key + * @param {Object} value The object to write + */ +export function set(key, value) { + localStorage.setItem(`${prefix}.${key}`, JSON.stringify(value)); +} + +/** + * Migrate outdated localStorage options + */ +export function migrate() { + // Add customTargetNames property to workout options (>1.4.1) + let workoutOptions = get('workout-calculator-options'); + if (workoutOptions !== null && workoutOptions.customTargetNames === undefined) { + workoutOptions.customTargetNames = false; + set('workout-calculator-options', workoutOptions); + } +} diff --git a/src/utils/targets.js b/src/utils/targets.js @@ -1,4 +1,5 @@ -import { convertDistance } from '@/utils/units'; +import { formatDuration, formatNumber } from '@/utils/format'; +import { DISTANCE_UNITS, convertDistance } from '@/utils/units'; /** * Sort an array of targets @@ -16,6 +17,23 @@ export function sort(targets) { ]; } +/** + * Generate a string description of a workout target + * @param {Object} target The workout target + * @return {String} The string description + */ +export function workoutTargetToString(target) { + let result = formatNumber(target.splitValue, 0, 2, false) + ' ' + + DISTANCE_UNITS[target.splitUnit].symbol; + if (target.type === 'time') { + result += ' @ ' + formatDuration(target.time, 3, 2, false); + } else if (target.distanceValue != target.splitValue || target.distanceUnit != target.splitUnit) { + result += ' @ ' + formatNumber(target.distanceValue, 0, 2, false) + ' ' + + DISTANCE_UNITS[target.distanceUnit].symbol; + } + return result; +} + export const defaultTargetSets = { '_pace_targets': { name: 'Common Pace Targets', diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue @@ -38,7 +38,15 @@ Target Set: <target-set-selector v-model:selectedTargetSet="selectedTargetSet" :setType="options.calculator === 'workout' ? 'workout' : 'standard'" - v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> + :customWorkoutNames="advancedOptions.customTargetNames" v-model:targetSets="targetSets" + :default-unit-system="defaultUnitSystem"/> + </div> + <div v-if="options.calculator === 'workout'"> + Target Name Customization: + <select v-model="advancedOptions.customTargetNames" aria-label="Target name customization"> + <option :value="false">Disabled</option> + <option :value="true">Enabled</option> + </select> </div> <race-options v-if="options.calculator !== 'pace'" v-model="advancedOptions"/> </details> @@ -117,6 +125,7 @@ const raceOptions = useStorage('race-calculator-options', { riegelExponent: 1.06, }); const workoutOptions = useStorage('workout-calculator-options', { + customTargetNames: false, model: 'AverageModel', riegelExponent: 1.06, }); diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue @@ -19,7 +19,15 @@ <div> Target Set: <target-set-selector v-model:selectedTargetSet="selectedTargetSet" setType="workout" - v-model:targetSets="targetSets" :default-unit-system="defaultUnitSystem"/> + :customWorkoutNames="options.customTargetNames" v-model:targetSets="targetSets" + :default-unit-system="defaultUnitSystem"/> + </div> + <div> + Target Name Customization: + <select v-model="options.customTargetNames" aria-label="Target name customization"> + <option :value="false">Disabled</option> + <option :value="true">Enabled</option> + </select> </div> <race-options v-model="options"/> </details> @@ -61,6 +69,7 @@ const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSys * The race prediction options */ const options = useStorage('workout-calculator-options', { + customTargetNames: false, model: 'AverageModel', riegelExponent: 1.06, }); diff --git a/tests/e2e/batch-calculator.spec.js b/tests/e2e/batch-calculator.spec.js @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test'; test('Batch calculator', async ({ page }) => { // Structure: - // - Test workout batch results, including modified prediction model + // - Test workout batch results, including modified prediction model and custom target names // - Test pace batch results, including modified default units // - Test race batch results, including modified Riegel exponent // - Reload page @@ -40,9 +40,10 @@ test('Batch calculator', async ({ page }) => { await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row')).toHaveCount(16); - // Change prediction model + // Change prediction model and enable customized target names await page.getByText('Advanced Options').click(); await page.getByLabel('Prediction model').selectOption('Riegel\'s Model'); + await page.getByLabel('Target name customization').selectOption('Enabled'); // Assert workout results are correct await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); @@ -160,6 +161,7 @@ test('Batch calculator', async ({ page }) => { // Assert workout results are correct (inputs and options not reset) await page.getByLabel('Calculator').selectOption('Workout Calculator'); + await expect(page.getByLabel('Target name customization')).toHaveValue("true"); await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km'); await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5); diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js @@ -107,9 +107,10 @@ test('Cross-calculator', async ({ page }) => { await page.getByLabel('Input race duration minutes').fill('5'); await page.getByLabel('Input race duration seconds').fill('1'); - // Change prediction model + // Change prediction model and enable target name customization await page.getByText('Advanced Options').click(); await page.getByLabel('Prediction model').selectOption('V̇O₂ Max Model'); + await page.getByLabel('Target name customization').selectOption('Enabled'); // Change default units (should update on other calculators too) await page.getByLabel('Default units').selectOption('Kilometers'); @@ -145,6 +146,7 @@ test('Cross-calculator', async ({ page }) => { // Assert workout results are correct (new workout options loaded) await page.getByLabel('Calculator').selectOption('Workout Calculator'); + await expect(page.getByLabel('Target name customization')).toHaveValue("true"); await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km'); await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5); diff --git a/tests/e2e/workout-calculator.spec.js b/tests/e2e/workout-calculator.spec.js @@ -47,6 +47,7 @@ test('Workout Calculator', async ({ page }) => { // Edit default target set await page.getByRole('button', { name: 'Edit target set' }).click(); await page.getByLabel('Target set label').fill('Less-common Workout Targets'); + await expect(page.getByLabel('Custom target name')).toHaveCount(0); 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(); @@ -73,6 +74,9 @@ test('Workout Calculator', async ({ page }) => { await expect(page.getByRole('row').nth(7)).toHaveText('2 mi' + '10:30.00'); await expect(page.getByRole('row')).toHaveCount(8); + // Enable target name customization + await page.getByLabel('Target name customization').selectOption('Enabled'); + // Create custom target set await page.getByLabel('Selected target set').selectOption('[ Create New Target Set ]'); await expect(page.getByRole('row').nth(4)).toHaveText('There aren\'t any targets in this set yet.'); @@ -83,6 +87,7 @@ test('Workout Calculator', async ({ page }) => { 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('Custom target name').last().fill('800m Interval'); 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'); @@ -95,7 +100,7 @@ test('Workout Calculator', async ({ page }) => { await page.getByRole('button', { name: 'Close' }).click(); // Assert workout splits are correct - await expect(page.getByRole('row').nth(1)).toHaveText('800 m @ 5 km' + '2:45.08'); + await expect(page.getByRole('row').nth(1)).toHaveText('800m Interval' + '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); @@ -103,7 +108,7 @@ test('Workout Calculator', async ({ 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(1)).toHaveText('800m Interval' + '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 @@ -15,16 +15,20 @@ test('should correctly render standard target set', async () => { ], }, setType: 'standard', + customWorkoutNames: true, // name input should not be rendered }, }); // 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].findAll('input').length).to.equal(0); 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].findAll('input').length).to.equal(0); 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[2].findAll('input').length).to.equal(0); expect(rows[2].findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600); expect(rows.length).to.equal(3); }); @@ -47,14 +51,16 @@ test('should correctly render split target set', async () => { // 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].findAll('input').length).to.equal(0); 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].findAll('input').length).to.equal(0); 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 () => { +test('should correctly render workout target set without custom names', async () => { // Initialize component const wrapper = shallowMount(TargetEditor, { propsData: { @@ -85,13 +91,71 @@ test('should correctly render workout target set', async () => { // 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].findAll('input').length).to.equal(0); 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].findAll('input').length).to.equal(0); 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].findAll('input').length).to.equal(0); + 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('should correctly render workout target set with custom names', async () => { + // Initialize component + const wrapper = shallowMount(TargetEditor, { + propsData: { + modelValue: { + name: 'My target set', + targets: [ + { + // customName is undefined + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + customName: '', + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + customName: 'my custom name', + distanceUnit: 'kilometers', distanceValue: 5, + splitUnit: 'miles', splitValue: 1, + type: 'distance' + }, + ], + }, + setType: 'workout', + customWorkoutNames: true, + }, + }); + + // 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].find('input').element.value).to.equal(''); + expect(rows[0].find('input').element.placeholder).to.equal('400 m @ 2 mi'); + 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].find('input').element.value).to.equal(''); + expect(rows[1].find('input').element.placeholder).to.equal('2 km @ 1:40:00'); + 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].find('input').element.value).to.equal('my custom name'); + expect(rows[2].find('input').element.placeholder).to.equal('1 mi @ 5 km'); 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); diff --git a/tests/unit/components/TargetSetSelector.spec.js b/tests/unit/components/TargetSetSelector.spec.js @@ -336,3 +336,20 @@ test('should correctly pass setType prop to TargetEditor', async () => { // Assert target editor props are correct expect(wrapper.findComponent({ name: 'target-editor' }).vm.setType).to.equal('foo'); }); + +test('should correctly pass customWorkoutNames prop to TargetEditor', async () => { + const wrapper = shallowMount(TargetSetSelector, { + propsData: { + customWorkoutNames: false, + } + }); + + // Assert target editor props are correct + expect(wrapper.findComponent({ name: 'target-editor' }).vm.customWorkoutNames).to.equal(false); + + // Update customWorkoutNames prop + await wrapper.setProps({ customWorkoutNames: true }); + + // Assert target editor props are correct + expect(wrapper.findComponent({ name: 'target-editor' }).vm.customWorkoutNames).to.equal(true); +}); diff --git a/tests/unit/utils/calculators.spec.js b/tests/unit/utils/calculators.spec.js @@ -1,201 +1,255 @@ -import { test, expect } from 'vitest'; +import { describe, test, expect } from 'vitest'; import * as calculatorUtils from '@/utils/calculators'; -test('should correctly calculate pace times', () => { - const input = { - distanceValue: 1, - distanceUnit: 'kilometers', - time: 100, - }; - const target = { - distanceValue: 20, - distanceUnit: 'meters', - type: 'distance', - }; - - const result = calculatorUtils.calculatePaceResults(input, target, 'metric'); - - expect(result).to.deep.equal({ - key: '20 m', - value: '0:02.00', - pace: '1:40 / km', - result: 'value', - sort: 2, +describe('calculatePaceResults method', () => { + test('should correctly calculate pace times', () => { + const input = { + distanceValue: 1, + distanceUnit: 'kilometers', + time: 100, + }; + const target = { + distanceValue: 20, + distanceUnit: 'meters', + type: 'distance', + }; + + const result = calculatorUtils.calculatePaceResults(input, target, 'metric'); + + expect(result).to.deep.equal({ + key: '20 m', + value: '0:02.00', + pace: '1:40 / km', + result: 'value', + sort: 2, + }); }); -}); -test('should correctly calculate pace distances according to default units setting', () => { - const input = { - distanceValue: 2, - distanceUnit: 'miles', - time: 1200, - }; - const target = { - time: 600, - type: 'time', - }; - - const result1 = calculatorUtils.calculatePaceResults(input, target, 'metric'); - const result2 = calculatorUtils.calculatePaceResults(input, target, 'imperial'); - - expect(result1.key).to.equal('1.61 km'); - expect(result1.value).to.equal('10:00'); - expect(result1.pace).to.equal('6:13 / km'); - expect(result1.result).to.equal('key'); - expect(result1.sort).to.be.closeTo(600, 0.01); - - expect(result2.key).to.equal('1.00 mi'); - expect(result2.value).to.equal('10:00'); - expect(result2.pace).to.equal('10:00 / mi'); - expect(result2.result).to.equal('key'); - expect(result2.sort).to.be.closeTo(600, 0.01); + test('should correctly calculate pace distances according to default units setting', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 1200, + }; + const target = { + time: 600, + type: 'time', + }; + + const result1 = calculatorUtils.calculatePaceResults(input, target, 'metric'); + const result2 = calculatorUtils.calculatePaceResults(input, target, 'imperial'); + + expect(result1.key).to.equal('1.61 km'); + expect(result1.value).to.equal('10:00'); + expect(result1.pace).to.equal('6:13 / km'); + expect(result1.result).to.equal('key'); + expect(result1.sort).to.be.closeTo(600, 0.01); + + expect(result2.key).to.equal('1.00 mi'); + expect(result2.value).to.equal('10:00'); + expect(result2.pace).to.equal('10:00 / mi'); + expect(result2.result).to.equal('key'); + expect(result2.sort).to.be.closeTo(600, 0.01); + }); }); -test('should correctly predict race times', () => { - const input = { - distanceValue: 5, - distanceUnit: 'kilometers', - time: 1200, - }; - const target = { - distanceValue: 10, - distanceUnit: 'kilometers', - type: 'distance', - }; - const options = { - model: 'AverageModel', - riegelExponent: 1.06, - } - - const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); - - expect(result.key).to.equal('10 km'); - expect(result.value).to.equal('41:34.80'); - expect(result.pace).to.equal('6:42 / mi'); - expect(result.result).to.equal('value'); - expect(result.sort).to.be.closeTo(2494.80, 0.01); -}); +describe('calculateRaceResults method', () => { + test('should correctly predict race times', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + distanceValue: 10, + distanceUnit: 'kilometers', + type: 'distance', + }; + const options = { + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result.key).to.equal('10 km'); + expect(result.value).to.equal('41:34.80'); + expect(result.pace).to.equal('6:42 / mi'); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(2494.80, 0.01); + }); -test('should correctly calculate race distances according to default units setting', () => { - const input = { - distanceValue: 5, - distanceUnit: 'kilometers', - time: 1200, - }; - const target = { - time: 2495, - type: 'time', - }; - const options = { - model: 'AverageModel', - riegelExponent: 1.06, - } - - const result1 = calculatorUtils.calculateRaceResults(input, target, options, 'metric'); - const result2 = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); - - expect(result1.key).to.equal('10.00 km'); - expect(result1.value).to.equal('41:35'); - expect(result1.pace).to.equal('4:09 / km'); - expect(result1.result).to.equal('key'); - expect(result1.sort).to.equal(2495); - - expect(result2.key).to.equal('6.21 mi'); - expect(result2.value).to.equal('41:35'); - expect(result2.pace).to.equal('6:41 / mi'); - expect(result2.result).to.equal('key'); - expect(result2.sort).to.equal(2495); -}); + test('should correctly calculate race distances according to default units setting', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; + const target = { + time: 2495, + type: 'time', + }; + const options = { + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result1 = calculatorUtils.calculateRaceResults(input, target, options, 'metric'); + const result2 = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result1.key).to.equal('10.00 km'); + expect(result1.value).to.equal('41:35'); + expect(result1.pace).to.equal('4:09 / km'); + expect(result1.result).to.equal('key'); + expect(result1.sort).to.equal(2495); + + expect(result2.key).to.equal('6.21 mi'); + expect(result2.value).to.equal('41:35'); + expect(result2.pace).to.equal('6:41 / mi'); + expect(result2.result).to.equal('key'); + expect(result2.sort).to.equal(2495); + }); -test('should correctly predict race times according to race options', () => { - const input = { - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - }; - const target = { - distanceValue: 5, - distanceUnit: 'kilometers', - type: 'distance', - }; - const options = { - model: 'RiegelModel', - riegelExponent: 1.12, - } - - const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); - - expect(result.key).to.equal('5 km'); - expect(result.value).to.equal('17:11.77'); - expect(result.pace).to.equal('5:32 / mi'); - expect(result.result).to.equal('value'); - expect(result.sort).to.be.closeTo(1031.77, 0.01); + test('should correctly predict race times according to race options', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target = { + distanceValue: 5, + distanceUnit: 'kilometers', + type: 'distance', + }; + const options = { + model: 'RiegelModel', + riegelExponent: 1.12, + } + + const result = calculatorUtils.calculateRaceResults(input, target, options, 'imperial'); + + expect(result.key).to.equal('5 km'); + expect(result.value).to.equal('17:11.77'); + expect(result.pace).to.equal('5:32 / mi'); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(1031.77, 0.01); + }); }); -test('should correctly calculate race statistics', () => { - const input = { - distanceValue: 5, - distanceUnit: 'kilometers', - time: 1200, - }; +describe('calculateRaceStats method', () => { + test('should correctly calculate race statistics', () => { + const input = { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }; - const results = calculatorUtils.calculateRaceStats(input); + const results = calculatorUtils.calculateRaceStats(input); - expect(results.purdyPoints).to.be.closeTo(454.5, 0.1); - expect(results.vo2).to.be.closeTo(47.4, 0.1); - expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1); - expect(results.vo2Max).to.be.closeTo(49.8, 0.1); + expect(results.purdyPoints).to.be.closeTo(454.5, 0.1); + expect(results.vo2).to.be.closeTo(47.4, 0.1); + expect(results.vo2MaxPercentage).to.be.closeTo(95.3, 0.1); + expect(results.vo2Max).to.be.closeTo(49.8, 0.1); + }); }); -test('should correctly calculate distance-based workouts according to race options', () => { - const input = { - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - }; - const target = { - distanceValue: 5, - distanceUnit: 'kilometers', // 5k split is ~17:11.77 - splitValue: 1000, - splitUnit: 'meters', - type: 'distance', - }; - const options = { - model: 'RiegelModel', - riegelExponent: 1.12, - } - - const result = calculatorUtils.calculateWorkoutResults(input, target, options); - - expect(result.key).to.equal('1000 m @ 5 km'); - expect(result.value).to.equal('3:26.35'); - expect(result.pace).to.equal(''); - expect(result.result).to.equal('value'); - expect(result.sort).to.be.closeTo(206.35, 0.01); -}); +describe('calculateWorkoutResults method', () => { + 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 = { + customTargetNames: false, + 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); + test('should correctly calculate distance-based workouts according to custom names', () => { + const input = { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }; + const target_1 = { + distanceValue: 5, + distanceUnit: 'kilometers', // 5k split is ~17:11.77 + splitValue: 1000, + splitUnit: 'meters', + type: 'distance', + // no custom name + }; + const target_2 = { + distanceValue: 5, + distanceUnit: 'kilometers', // 5k split is ~17:11.77 + splitValue: 1000, + splitUnit: 'meters', + type: 'distance', + customName: 'my custom name', + }; + const options_a = { + customTargetNames: false, + model: 'RiegelModel', + riegelExponent: 1.12, + }; + const options_b = { + customTargetNames: true, + model: 'RiegelModel', + riegelExponent: 1.12, + }; + + const result1a = calculatorUtils.calculateWorkoutResults(input, target_1, options_a); + const result1b = calculatorUtils.calculateWorkoutResults(input, target_1, options_b); + const result2a = calculatorUtils.calculateWorkoutResults(input, target_2, options_a); + const result2b = calculatorUtils.calculateWorkoutResults(input, target_2, options_b); + + expect(result1a.key).to.equal('1000 m @ 5 km'); + expect(result1b.key).to.equal('1000 m @ 5 km'); + expect(result2a.key).to.equal('1000 m @ 5 km'); + expect(result2b.key).to.equal('my custom name'); + }); + + 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 = { + customTargetNames: false, + model: 'AverageModel', + riegelExponent: 1.06, + } + + const result = calculatorUtils.calculateWorkoutResults(input, target, options); + + expect(result.key).to.equal('1 mi @ 41:35'); + expect(result.value).to.equal('6:41.50'); + expect(result.pace).to.equal(''); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(401.50, 0.01); + }); }); diff --git a/tests/unit/utils/storage.spec.js b/tests/unit/utils/storage.spec.js @@ -0,0 +1,89 @@ +import { beforeEach, describe, test, expect } from 'vitest'; +import * as storage from '@/utils/storage'; + +beforeEach(() => { + localStorage.clear(); +}) + +describe('get method', () => { + test('should correctly parse correct localStorage item', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.foo', '{"bar":123}'); + + // Assert result is correct + expect(storage.get('foo')).to.deep.equal({ bar: 123 }); + }); + + test('should return null for corrupt localStorage item', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.foo', 'invalid json'); + + // Assert result is correct + expect(storage.get('foo')).to.equal(null); + }); + + test('should return null for missing localStorage item', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.foo', '{"bar":123}'); + + // Assert result is correct + expect(storage.get('baz')).to.equal(null); + }); +}); + +describe('set method', () => { + test('should correctly set new localStorage item', async () => { + // Set localStorage item + storage.set('foo', { baz: 456 }); + + // Assert result is correct + expect(localStorage.getItem('running-tools.foo')).to.equal('{"baz":456}'); + }); + + test('should correctly override existing localStorage item', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.foo', '{"bar":123}'); + + // Set localStorage item + storage.set('foo', { baz: 456 }); + + // Assert result is correct + expect(localStorage.getItem('running-tools.foo')).to.equal('{"baz":456}'); + }); +}); + +describe('migrate method', () => { + test('should correctly migrate <=1.4.1 workout calculator options', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.workout-calculator-options', + '{"model":"PurdyPointsModel","riegelExponent":1.1}'); + + // Run migratinos + storage.migrate(); + + // Assert localStorage entries correctly migrated + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( + '{"model":"PurdyPointsModel","riegelExponent":1.1,"customTargetNames":false}'); + }); + + test('should not modify >1.4.1 workout calculator options', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.workout-calculator-options', + '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.1}'); + + // Run migratinos + storage.migrate(); + + // Assert localStorage entries not modified + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( + '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.1}'); + }); + + test('should not modify missing workout calculator options', async () => { + // Run migratinos + storage.migrate(); + + // Assert localStorage entries not modified + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(null); + }); +}); diff --git a/tests/unit/utils/targets.spec.js b/tests/unit/utils/targets.spec.js @@ -19,3 +19,41 @@ describe('sort method', () => { expect(targets.sort(input)).to.deep.equal(expected); }); }); + +describe('workoutTargetToString method', () => { + test('should correctly stringify time target', () => { + // Initialize original and stringified target + const input = { + splitValue: 1600, splitUnit: 'meters', + type: 'time', time: 3600, + }; + const expected = '1600 m @ 1:00:00'; + + // Assert sort method sorts targets correctly + expect(targets.workoutTargetToString(input)).to.deep.equal(expected); + }); + + test('should correctly stringify distance target', () => { + // Initialize original and stringified target + const input = { + splitValue: 800, splitUnit: 'meters', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', + }; + const expected = '800 m @ 5 km'; + + // Assert sort method sorts targets correctly + expect(targets.workoutTargetToString(input)).to.deep.equal(expected); + }); + + test('should correctly stringify race target', () => { + // Initialize original and stringified target + const input = { + splitValue: 5, splitUnit: 'kilometers', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', + }; + const expected = '5 km'; + + // Assert sort method sorts targets correctly + expect(targets.workoutTargetToString(input)).to.deep.equal(expected); + }); +}); diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js @@ -73,6 +73,7 @@ test('should correctly calculate results according to advanced model options', a // Calculate result const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; let result = calculateResult({ + customName: 'foo', splitValue: 1, splitUnit: 'kilometers', type: 'distance', distanceValue: 10, distanceUnit: 'kilometers', });