running-tools

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

commit 1a032514433fbeab6092e6117636a02b23f7bb8c
parent baf7d0840cf02ec30ba13068dc64deb4c28969d9
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Mon, 27 May 2024 14:11:01 -0700

Implement PaceInput component

Diffstat:
Msrc/assets/target-calculator.css | 3---
Asrc/components/PaceInput.vue | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/views/PaceCalculator.vue | 41+++++++++++------------------------------
Msrc/views/RaceCalculator.vue | 64+++++++++++++++++++++++-----------------------------------------
Mtests/e2e/cross-calculator.spec.js | 4++--
Mtests/e2e/race-calculator.spec.js | 12++++++------
Atests/unit/components/PaceInput.spec.js | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/views/PaceCalculator.spec.js | 48++++++++++++++++++++++++++++++------------------
Mtests/unit/views/RaceCalculator.spec.js | 64++++++++++++++++++++++++++++++++++++++++------------------------
9 files changed, 220 insertions(+), 124 deletions(-)

diff --git a/src/assets/target-calculator.css b/src/assets/target-calculator.css @@ -18,9 +18,6 @@ h2 { .input>* { margin-bottom: 5px; /* adds space between wrapped lines */ } -.input select { - margin-left: 5px; -} /* collapsable sections */ summary { diff --git a/src/components/PaceInput.vue b/src/components/PaceInput.vue @@ -0,0 +1,58 @@ +<template> + <div class="pace-input"> + <div> + Distance: + <decimal-input v-model="model.distanceValue" + :aria-label="label + ' distance value'" :min="0" :digits="2"/> + <select v-model="model.distanceUnit" :aria-label="label + ' distance unit'"> + <option v-for="(value, key) in distanceUnits" :key="key" :value="key"> + {{ value.name }} + </option> + </select> + </div> + <div> + Time: + <time-input v-model="model.time" :label="label + ' duration'"/> + </div> + </div> +</template> + +<script setup> +import DecimalInput from '@/components/DecimalInput.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import unitUtils from '@/utils/units'; +const distanceUnits = unitUtils.DISTANCE_UNITS; + +/** + * The component value + */ +const model = defineModel({ + type: Object, + default: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, +}); + +defineProps({ + /** + * The prefix for each field's aria-label + */ + label: { + type: String, + default: 'Input', + }, +}); + +</script> + +<style scoped> +.pace-input div + div { + margin-top: 5px; +} +.pace-input select { + margin-left: 5px; +} +</style> diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -2,20 +2,7 @@ <div class="calculator"> <h2>Input Pace</h2> <div class="input"> - <div> - Distance: - <decimal-input v-model="inputDistance" aria-label="Input distance value" - :min="0" :digits="2"/> - <select v-model="inputUnit" aria-label="Input distance unit"> - <option v-for="(value, key) in unitUtils.DISTANCE_UNITS" :key="key" :value="key"> - {{ value.name }} - </option> - </select> - </div> - <div> - Time: - <time-input v-model="inputTime" label="Input duration"/> - </div> + <pace-input v-model="input"/> </div> <details> @@ -49,27 +36,20 @@ import paceUtils from '@/utils/paces'; import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; -import DecimalInput from '@/components/DecimalInput.vue'; +import PaceInput from '@/components/PaceInput.vue'; import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; -import TimeInput from '@/components/TimeInput.vue'; import useStorage from '@/composables/useStorage'; /** - * The input distance value - */ -const inputDistance = useStorage('pace-calculator-input-distance', 5); - -/** - * The input distance unit - */ -const inputUnit = useStorage('pace-calculator-input-unit', 'kilometers'); - -/** - * The input time value + * The input pace */ -const inputTime = useStorage('pace-calculator-input-time', 20 * 60); +const input = useStorage('pace-calculator-input', { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, +}); /** * The default unit system @@ -90,8 +70,9 @@ const targetSets = useStorage('target-sets', targetUtils.defaultTargetSets); * The input pace (in seconds per meter) */ const pace = computed(() => { - const distance = unitUtils.convertDistance(inputDistance.value, inputUnit.value, 'meters'); - return paceUtils.getPace(distance, inputTime.value); + const distance = unitUtils.convertDistance(input.value.distanceValue, input.value.distanceUnit, + 'meters'); + return paceUtils.getPace(distance, input.value.time); }); /** diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -2,19 +2,7 @@ <div class="calculator"> <h2>Input Race Result</h2> <div class="input"> - <div> - Distance: - <decimal-input v-model="inputDistance" aria-label="Input distance value" :min="0" :digits="2"/> - <select v-model="inputUnit" aria-label="Input distance unit"> - <option v-for="(value, key) in unitUtils.DISTANCE_UNITS" :key="key" :value="key"> - {{ value.name }} - </option> - </select> - </div> - <div> - Time: - <time-input v-model="inputTime" label="Input race duration"/> - </div> + <pace-input v-model="input" label="Input race"/> </div> <details> @@ -82,26 +70,20 @@ import targetUtils from '@/utils/targets'; import unitUtils from '@/utils/units'; import DecimalInput from '@/components/DecimalInput.vue'; +import PaceInput from '@/components/PaceInput.vue'; import SimpleTargetTable from '@/components/SimpleTargetTable.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; -import TimeInput from '@/components/TimeInput.vue'; import useStorage from '@/composables/useStorage'; /** - * The input distance value - */ -const inputDistance = useStorage('race-calculator-input-distance', 5); - -/** - * The input distance unit - */ -const inputUnit = useStorage('race-calculator-input-unit', 'kilometers'); - -/** - * The input time value + * The input race */ -const inputTime = useStorage('race-calculator-input-time', 20 * 60); +const input = useStorage('race-calculator-input', { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, +}); /** * The default unit system @@ -152,21 +134,21 @@ function predictResult(target) { switch (model.value) { default: case 'AverageModel': - time = raceUtils.AverageModel.predictTime(d1.value, inputTime.value, d2, + time = raceUtils.AverageModel.predictTime(d1.value, input.value.time, d2, riegelExponent.value); break; case 'PurdyPointsModel': - time = raceUtils.PurdyPointsModel.predictTime(d1.value, inputTime.value, d2); + time = raceUtils.PurdyPointsModel.predictTime(d1.value, input.value.time, d2); break; case 'VO2MaxModel': - time = raceUtils.VO2MaxModel.predictTime(d1.value, inputTime.value, d2); + time = raceUtils.VO2MaxModel.predictTime(d1.value, input.value.time, d2); break; case 'RiegelModel': - time = raceUtils.RiegelModel.predictTime(d1.value, inputTime.value, d2, + time = raceUtils.RiegelModel.predictTime(d1.value, input.value.time, d2, riegelExponent.value); break; case 'CameronModel': - time = raceUtils.CameronModel.predictTime(d1.value, inputTime.value, d2); + time = raceUtils.CameronModel.predictTime(d1.value, input.value.time, d2); break; } @@ -178,22 +160,22 @@ function predictResult(target) { switch (model.value) { default: case 'AverageModel': - distance = raceUtils.AverageModel.predictDistance(inputTime.value, d1.value, target.time, + distance = raceUtils.AverageModel.predictDistance(input.value.time, d1.value, target.time, riegelExponent.value); break; case 'PurdyPointsModel': - distance = raceUtils.PurdyPointsModel.predictDistance(inputTime.value, d1.value, + distance = raceUtils.PurdyPointsModel.predictDistance(input.value.time, d1.value, target.time); break; case 'VO2MaxModel': - distance = raceUtils.VO2MaxModel.predictDistance(inputTime.value, d1.value, target.time); + distance = raceUtils.VO2MaxModel.predictDistance(input.value.time, d1.value, target.time); break; case 'RiegelModel': - distance = raceUtils.RiegelModel.predictDistance(inputTime.value, d1.value, target.time, + distance = raceUtils.RiegelModel.predictDistance(input.value.time, d1.value, target.time, riegelExponent.value); break; case 'CameronModel': - distance = raceUtils.CameronModel.predictDistance(inputTime.value, d1.value, target.time); + distance = raceUtils.CameronModel.predictDistance(input.value.time, d1.value, target.time); break; } @@ -214,14 +196,14 @@ function predictResult(target) { * The input distance in meters */ const d1 = computed(() => { - return unitUtils.convertDistance(inputDistance.value, inputUnit.value, 'meters'); + return unitUtils.convertDistance(input.value.distanceValue, input.value.distanceUnit, 'meters'); }); /** * The Purdy Points for the input race */ const purdyPoints = computed(() => { - const result = raceUtils.PurdyPointsModel.getPurdyPoints(d1.value, inputTime.value); + const result = raceUtils.PurdyPointsModel.getPurdyPoints(d1.value, input.value.time); return result; }); @@ -229,7 +211,7 @@ const purdyPoints = computed(() => { * The VO2 Max calculated from the input race */ const vo2Max = computed(() => { - const result = raceUtils.VO2MaxModel.getVO2Max(d1.value, inputTime.value); + const result = raceUtils.VO2MaxModel.getVO2Max(d1.value, input.value.time); return result; }); @@ -237,7 +219,7 @@ const vo2Max = computed(() => { * The VO2 calculated from the input race */ const vo2 = computed(() => { - const result = raceUtils.VO2MaxModel.getVO2(d1.value, inputTime.value); + const result = raceUtils.VO2MaxModel.getVO2(d1.value, input.value.time); return result; }); @@ -245,7 +227,7 @@ const vo2 = computed(() => { * The percentage of VO2 Max calculated from the input race */ const vo2Percentage = computed(() => { - const result = raceUtils.VO2MaxModel.getVO2Percentage(inputTime.value) * 100; + const result = raceUtils.VO2MaxModel.getVO2Percentage(input.value.time) * 100; return result; }); </script> diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js @@ -24,8 +24,8 @@ test('Save and update state when navigating between calculators', async ({ page await page.getByRole('button', { name: 'Race Calculator' }).click(); // Enter input race (2 mi in 10:30) - await page.getByLabel('Input distance value').fill('2'); - await page.getByLabel('Input distance unit').selectOption('Miles'); + 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'); diff --git a/tests/e2e/race-calculator.spec.js b/tests/e2e/race-calculator.spec.js @@ -7,8 +7,8 @@ test('Basic usage', async ({ page }) => { await expect(page).toHaveTitle('Race Calculator - Running Tools'); // Enter input race (2 mi in 10:30) - await page.getByLabel('Input distance value').fill('2'); - await page.getByLabel('Input distance unit').selectOption('Miles'); + 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'); @@ -56,8 +56,8 @@ test('Customize target sets', async ({ page }) => { await page.getByRole('button', { name: 'Race Calculator' }).click(); // Enter input race (2 mi in 10:30) - await page.getByLabel('Input distance value').fill('2'); - await page.getByLabel('Input distance unit').selectOption('Miles'); + 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'); @@ -148,8 +148,8 @@ test('Save settings across page reloads', async ({ page }) => { await page.getByRole('button', { name: 'Race Calculator' }).click(); // Enter input race (2 mi in 10:30) - await page.getByLabel('Input distance value').fill('2'); - await page.getByLabel('Input distance unit').selectOption('Miles'); + 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'); diff --git a/tests/unit/components/PaceInput.spec.js b/tests/unit/components/PaceInput.spec.js @@ -0,0 +1,50 @@ +import { test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import PaceInput from '@/components/PaceInput.vue'; + +test('should be initialized to modelValue', () => { + // Initialize component + const wrapper = shallowMount(PaceInput, { + propsData: { + modelValue: { + distanceValue: 3, + distanceUnit: 'miles', + time: 1000, + } + }, + }); + + // Assert input fields are correct + expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(3); + expect(wrapper.find('select').element.value).to.equal('miles'); + expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(1000); +}); + +test('should update modelValue when inputs are modified', async () => { + // Initialize component + const wrapper = shallowMount(PaceInput); + + // Update distance value + await wrapper.findComponent({ name: 'decimal-input' }).setValue(3); + expect(wrapper.vm.modelValue).to.deep.equal({ + distanceValue: 3, + distanceUnit: 'kilometers', + time: 1200, + }); + + // Update distance unit + await wrapper.find('select').setValue('miles'); + expect(wrapper.vm.modelValue).to.deep.equal({ + distanceValue: 3, + distanceUnit: 'miles', + time: 1200, + }); + + // Update time + await wrapper.findComponent({ name: 'time-input' }).setValue(1000); + expect(wrapper.vm.modelValue).to.deep.equal({ + distanceValue: 3, + distanceUnit: 'miles', + time: 1000, + }); +}); diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js @@ -12,9 +12,11 @@ test('should correctly calculate time results', async () => { const wrapper = shallowMount(PaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(1); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(100); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'kilometers', + time: 100, + }); // Calculate result const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult; @@ -38,9 +40,11 @@ test('should correctly calculate distance results according to default units set const wrapper = shallowMount(PaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(2); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 2, + distanceUnit: 'miles', + time: 1200, + }); // Set default units await wrapper.find('select[aria-label="Default units"]').setValue('metric'); @@ -95,17 +99,21 @@ test('should correctly handle null target set', async () => { test('should load input pace from localStorage', async () => { // Initialize localStorage - localStorage.setItem('running-tools.pace-calculator-input-distance', '1'); - localStorage.setItem('running-tools.pace-calculator-input-unit', '"miles"'); - localStorage.setItem('running-tools.pace-calculator-input-time', '600'); + localStorage.setItem('running-tools.pace-calculator-input', JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); // Initialize component const wrapper = shallowMount(PaceCalculator); // Assert data loaded - expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1); - expect(wrapper.find('select[aria-label="Input distance unit"]').element.value).to.equal('miles'); - expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600); + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); }); test('should save input pace to localStorage', async () => { @@ -113,14 +121,18 @@ test('should save input pace to localStorage', async () => { const wrapper = shallowMount(PaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(1); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles'); - await wrapper.findComponent({ name: 'time-input' }).setValue(600); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); // Assert data saved to localStorage - expect(localStorage.getItem('running-tools.pace-calculator-input-distance')).to.equal('1'); - expect(localStorage.getItem('running-tools.pace-calculator-input-unit')).to.equal('"miles"'); - expect(localStorage.getItem('running-tools.pace-calculator-input-time')).to.equal('600'); + expect(localStorage.getItem('running-tools.pace-calculator-input')).to.equal(JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); }); test('should load selected target set from localStorage', async () => { diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -12,9 +12,11 @@ test('should correctly predict race times', async () => { const wrapper = shallowMount(RaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(5); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); // Calculate result const calculateResult = wrapper.findComponent({ name: 'simple-target-table' }).vm.calculateResult; @@ -36,9 +38,11 @@ test('should correctly calculate distance results according to default units set const wrapper = shallowMount(RaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(5); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); // Set default units await wrapper.find('select[aria-label="Default units"]').setValue('metric'); @@ -98,9 +102,11 @@ test('should correctly calculate race statistics', async () => { const wrapper = shallowMount(RaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(5); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); // Get race statistics const raceStats = wrapper.findAll('details')[0]; @@ -119,9 +125,11 @@ test('should correctly calculate results according to advanced model options', a const wrapper = shallowMount(RaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(5); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('kilometers'); - await wrapper.findComponent({ name: 'time-input' }).setValue(1200); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); // Switch model await wrapper.find('select[aria-label="Prediction model"]').setValue('RiegelModel'); @@ -154,17 +162,21 @@ test('should correctly calculate results according to advanced model options', a test('should load input pace from localStorage', async () => { // Initialize localStorage - localStorage.setItem('running-tools.race-calculator-input-distance', '1'); - localStorage.setItem('running-tools.race-calculator-input-unit', '"miles"'); - localStorage.setItem('running-tools.race-calculator-input-time', '600'); + localStorage.setItem('running-tools.race-calculator-input', JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); // Initialize component const wrapper = shallowMount(RaceCalculator); // Assert data loaded - expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1); - expect(wrapper.find('select[aria-label="Input distance unit"]').element.value).to.equal('miles'); - expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(600); + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); }); test('should save input pace to localStorage', async () => { @@ -172,14 +184,18 @@ test('should save input pace to localStorage', async () => { const wrapper = shallowMount(RaceCalculator); // Enter input pace data - await wrapper.findComponent({ name: 'decimal-input' }).setValue(1); - await wrapper.find('select[aria-label="Input distance unit"]').setValue('miles'); - await wrapper.findComponent({ name: 'time-input' }).setValue(600); + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); // Assert data saved to localStorage - expect(localStorage.getItem('running-tools.race-calculator-input-distance')).to.equal('1'); - expect(localStorage.getItem('running-tools.race-calculator-input-unit')).to.equal('"miles"'); - expect(localStorage.getItem('running-tools.race-calculator-input-time')).to.equal('600'); + expect(localStorage.getItem('running-tools.race-calculator-input')).to.equal(JSON.stringify({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + })); }); test('should load selected target set from localStorage', async () => {