running-tools

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

commit 6b28796f0472785d82c9effcc626d6232d4d54c1
parent 79d6bee6e188ce7e66ecf9bd6923100e5789fbaf
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Fri, 15 Aug 2025 14:03:58 -0700

Create RacePredictionOptions interface

Use RacePredictionOptions type in calculator options types. Also remove
legacy RaceOptionsInput component and several default function argument
values. Migration scripts and end-to-end tests not yet updated.

Diffstat:
Msrc/components/AdvancedOptionsInput.vue | 9+++++----
Dsrc/components/RaceOptionsInput.vue | 39---------------------------------------
Msrc/core/calculators.ts | 37+++++++++++++++++++++----------------
Msrc/core/racePrediction.ts | 34++++++++++++++++++++--------------
Msrc/views/BatchCalculator.vue | 4++--
Msrc/views/RaceCalculator.vue | 3++-
Msrc/views/WorkoutCalculator.vue | 3++-
Mtests/unit/components/AdvancedOptionsInput.spec.js | 78++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Dtests/unit/components/RaceOptionsInput.spec.js | 53-----------------------------------------------------
Mtests/unit/core/calculators.spec.js | 58++++++++++++++++++++++++++++++----------------------------
Mtests/unit/core/racePrediction.spec.js | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mtests/unit/views/BatchCalculator.spec.js | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mtests/unit/views/RaceCalculator.spec.js | 48++++++++++++++++++++++++++++++++----------------
Mtests/unit/views/WorkoutCalculator.spec.js | 6++++--
14 files changed, 342 insertions(+), 260 deletions(-)

diff --git a/src/components/AdvancedOptionsInput.vue b/src/components/AdvancedOptionsInput.vue @@ -33,7 +33,8 @@ <div v-if="props.type === Calculators.Race || props.type === Calculators.Workout"> Prediction model: - <select v-model="(options as RaceOptions).model" aria-label="Prediction model"> + <select v-model="(options as RaceOptions).predictionOptions.model" + aria-label="Prediction model"> <option :value="RacePredictionModels.AverageModel">Average</option> <option :value="RacePredictionModels.PurdyPointsModel">Purdy Points Model</option> <option :value="RacePredictionModels.VO2MaxModel">V&#775;O&#8322; Max Model</option> @@ -43,10 +44,10 @@ </div> <div v-if="props.type === Calculators.Race || props.type === Calculators.Workout" - v-show="(options as RaceOptions).model == RacePredictionModels.AverageModel || - (options as RaceOptions).model == RacePredictionModels.RiegelModel"> + v-show="(options as RaceOptions).predictionOptions.model == RacePredictionModels.AverageModel + || (options as RaceOptions).predictionOptions.model == RacePredictionModels.RiegelModel"> Riegel exponent: - <decimal-input v-model="(options as RaceOptions).riegelExponent" + <decimal-input v-model="(options as RaceOptions).predictionOptions.riegelExponent" aria-label="Riegel exponent" :min="1" :max="1.3" :digits="2" :step="0.01"/> (default: 1.06) </div> diff --git a/src/components/RaceOptionsInput.vue b/src/components/RaceOptionsInput.vue @@ -1,39 +0,0 @@ -<template> - <div> - Prediction Model: - <select v-model="model.model" aria-label="Prediction model"> - <option value="AverageModel">Average</option> - <option value="PurdyPointsModel">Purdy Points Model</option> - <option value="VO2MaxModel">V&#775;O&#8322; Max Model</option> - <option value="CameronModel">Cameron's Model</option> - <option value="RiegelModel">Riegel's Model</option> - </select> - </div> - <div> - Riegel Exponent: - <decimal-input v-model="model.riegelExponent" aria-label="Riegel exponent" :min="1" :max="1.3" - :digits="2" :step="0.01"/> - (default: 1.06) - </div> -</template> - -<script setup lang="ts"> -import type { RaceOptions } from '@/core/calculators'; - -import DecimalInput from '@/components/DecimalInput.vue'; -import useObjectModel from '@/composables/useObjectModel'; - -interface Props { - /** - * The component value - */ - modelValue: RaceOptions, -} - -const props = defineProps<Props>(); - -// Generate internal ref tied to modelValue prop -const emit = defineEmits(['update:modelValue']); -const model = useObjectModel<RaceOptions>(() => props.modelValue, - (x) => emit('update:modelValue', x)); -</script> diff --git a/src/core/calculators.ts b/src/core/calculators.ts @@ -3,6 +3,7 @@ */ import * as racePrediction from '@/core/racePrediction'; +import type { RacePredictionOptions } from '@/core/racePrediction'; import { TargetTypes, workoutTargetToString } from '@/core/targets'; import type { StandardTarget, WorkoutTarget } from '@/core/targets'; import { DistanceUnits, UnitSystems, convertDistance, formatDistance, formatDuration, formatPace, @@ -38,8 +39,7 @@ export interface StandardOptions { selectedTargetSet: string, } export interface RaceOptions extends StandardOptions { - model: racePrediction.RacePredictionModels, - riegelExponent: number, + predictionOptions: RacePredictionOptions, }; export interface WorkoutOptions extends RaceOptions { customTargetNames: boolean, @@ -88,8 +88,10 @@ export const defaultPaceOptions: StandardOptions = { selectedTargetSet: '_pace_targets', }; export const defaultRaceOptions: RaceOptions = { - model: racePrediction.RacePredictionModels.AverageModel, - riegelExponent: 1.06, + predictionOptions: { + model: racePrediction.RacePredictionModels.AverageModel, + riegelExponent: 1.06, + }, selectedTargetSet: '_race_targets', }; export const defaultSplitOptions: StandardOptions = { @@ -165,7 +167,7 @@ function calculateStandardResult(input: DistanceTime, target: StandardTarget, */ export function calculatePaceResults(input: DistanceTime, target: StandardTarget, defaultUnitSystem: UnitSystems, - preciseDurations: boolean = true): TargetResult { + preciseDurations: boolean): TargetResult { return calculateStandardResult(input, target, (d1, t1, d2) => ((t1 / d1) * d2), (t1, d1, t2) => ((d1 / t1) * t2), defaultUnitSystem, preciseDurations); @@ -175,18 +177,19 @@ export function calculatePaceResults(input: DistanceTime, target: StandardTarget * Predict race results from a target * @param {DistanceTime} input The input race * @param {StandardTarget} target The race target - * @param {RaceOptions} options The race prediction options + * @param {RacePredictionOptions} racePredictionOptions The race prediction options * @param {UnitSystems} defaultUnitSystem The default unit system (imperial or metric) * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations * @returns {TargetResult} The result */ export function calculateRaceResults(input: DistanceTime, target: StandardTarget, - options: RaceOptions, defaultUnitSystem: UnitSystems, - preciseDurations: boolean = true): TargetResult { + racePredictionOptions: RacePredictionOptions, + defaultUnitSystem: UnitSystems, preciseDurations: boolean + ): TargetResult { return calculateStandardResult(input, target, - (d1, t1, d2) => racePrediction.predictTime(d1, t1, d2, options.model, options.riegelExponent), - (t1, d1, t2) => racePrediction.predictDistance(t1, d1, t2, options.model, options.riegelExponent), + (d1, t1, d2) => racePrediction.predictTime(d1, t1, d2, racePredictionOptions), + (t1, d1, t2) => racePrediction.predictDistance(t1, d1, t2, racePredictionOptions), defaultUnitSystem, preciseDurations); } @@ -210,13 +213,15 @@ export function calculateRaceStats(input: DistanceTime): RaceStats { * Predict workout results from a target * @param {DistanceTime} input The input race * @param {WorkoutTarget} target The workout target - * @param {WorkoutOptions} options The workout options + * @param {RacePredictionOptions} racePredictionOptions The race prediction options + * @param {Boolean} customTargetNames Whether to use custom target names * @param {Boolean} preciseDurations Whether to return precise, unrounded, durations * @returns {TargetResult} The result */ export function calculateWorkoutResults(input: DistanceTime, target: WorkoutTarget, - options: WorkoutOptions, - preciseDurations: boolean = true): TargetResult { + racePredictionOptions: RacePredictionOptions, + customTargetNames: boolean, preciseDurations: boolean + ): TargetResult { // Initialize distance and time variables const d1 = convertDistance(input.distanceValue, input.distanceUnit, DistanceUnits.Meters); const t1 = input.time; @@ -229,18 +234,18 @@ export function calculateWorkoutResults(input: DistanceTime, target: WorkoutTarg d2 = convertDistance(target.distanceValue, target.distanceUnit, DistanceUnits.Meters); // Get workout split prediction - t2 = racePrediction.predictTime(d1, input.time, d2, options.model, options.riegelExponent); + t2 = racePrediction.predictTime(d1, input.time, d2, racePredictionOptions); } else { t2 = target.time; // Get workout split prediction - d2 = racePrediction.predictDistance(t1, d1, t2, options.model, options.riegelExponent); + d2 = racePrediction.predictDistance(t1, d1, t2, racePredictionOptions); } const t3 = (t2 / d2) * d3; // Return result return { - key: (options.customTargetNames && target.customName) || workoutTargetToString(target), + key: (customTargetNames && target.customName) || workoutTargetToString(target), value: formatDuration(t3, 3, preciseDurations ? 2 : 0, true), pace: '', // Pace not used in workout calculator result: ResultType.Value, diff --git a/src/core/racePrediction.ts b/src/core/racePrediction.ts @@ -14,6 +14,14 @@ export enum RacePredictionModels { }; /* + * The type for race prediction options + */ +export interface RacePredictionOptions { + model: RacePredictionModels, + riegelExponent: number, +}; + +/* * The type for internal variables used by the Purdy Points race prediction model */ interface PurdyPointsVariables { @@ -438,22 +446,21 @@ const AverageModel = { * @param {number} d1 The distance of the input race in meters * @param {number} t1 The finish time of the input race in seconds * @param {number} d2 The distance of the output race in meters - * @param {string} model The race prediction model to use - * @param {number} c The value of the exponent in Pete Riegel's Model + * @param {RacePredictionOptions} options The race prediction options + * @param {number} The predicted finish time in seconds */ export function predictTime(d1: number, t1: number, d2: number, - model: RacePredictionModels = RacePredictionModels.AverageModel, - c: number = 1.06): number { - switch (model) { + options: RacePredictionOptions): number { + switch (options.model) { default: case RacePredictionModels.AverageModel: - return AverageModel.predictTime(d1, t1, d2, c); + return AverageModel.predictTime(d1, t1, d2, options.riegelExponent); case RacePredictionModels.PurdyPointsModel: return PurdyPointsModel.predictTime(d1, t1, d2); case RacePredictionModels.VO2MaxModel: return VO2MaxModel.predictTime(d1, t1, d2); case RacePredictionModels.RiegelModel: - return RiegelModel.predictTime(d1, t1, d2, c); + return RiegelModel.predictTime(d1, t1, d2, options.riegelExponent); case RacePredictionModels.CameronModel: return CameronModel.predictTime(d1, t1, d2); } @@ -464,22 +471,21 @@ export function predictTime(d1: number, t1: number, d2: number, * @param {number} t1 The finish time of the input race in seconds * @param {number} d1 The distance of the input race in meters * @param {number} t2 The finish time of the output race in seconds - * @param {string} model The race prediction model to use - * @param {number} c The value of the exponent in Pete Riegel's Model + * @param {RacePredictionOptions} options The race prediction options + * @param {number} The predicted finish distance in meters */ export function predictDistance(t1: number, d1: number, t2: number, - model: RacePredictionModels = RacePredictionModels.AverageModel, - c: number = 1.06) { - switch (model) { + options: RacePredictionOptions): number { + switch (options.model) { default: case RacePredictionModels.AverageModel: - return AverageModel.predictDistance(t1, d1, t2, c); + return AverageModel.predictDistance(t1, d1, t2, options.riegelExponent); case RacePredictionModels.PurdyPointsModel: return PurdyPointsModel.predictDistance(t1, d1, t2); case RacePredictionModels.VO2MaxModel: return VO2MaxModel.predictDistance(t1, d1, t2); case RacePredictionModels.RiegelModel: - return RiegelModel.predictDistance(t1, d1, t2, c); + return RiegelModel.predictDistance(t1, d1, t2, options.riegelExponent); case RacePredictionModels.CameronModel: return CameronModel.predictDistance(t1, d1, t2); } diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue @@ -196,13 +196,13 @@ const calculateResult = computed<(x: DistanceTime, y: targetUtils.Target) => Tar return (x,y) => calculators.calculatePaceResults(x, y, defaultUnitSystem.value, false); } case (calculators.Calculators.Race): { - return (x,y) => calculators.calculateRaceResults(x, y, raceOptions.value, + return (x,y) => calculators.calculateRaceResults(x, y, raceOptions.value.predictionOptions, defaultUnitSystem.value, false); } default: case (calculators.Calculators.Workout): { return (x,y) => calculators.calculateWorkoutResults(x, y as targetUtils.WorkoutTarget, - workoutOptions.value, false); + workoutOptions.value.predictionOptions, workoutOptions.value.customTargetNames, false); } } }); diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -32,7 +32,8 @@ <h2>Equivalent Race Results</h2> <single-output-table class="output" show-pace - :calculate-result="x => calculateRaceResults(input, x, options, defaultUnitSystem, true)" + :calculate-result="x => calculateRaceResults(input, x, options.predictionOptions, + defaultUnitSystem, true)" :targets="targetSets[options.selectedTargetSet] ? targetSets[options.selectedTargetSet].targets : []"/> </div> diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue @@ -15,7 +15,8 @@ <h2>Workout Splits</h2> <single-output-table class="output" - :calculate-result="x => calculateWorkoutResults(input, x as WorkoutTarget, options, true)" + :calculate-result="x => calculateWorkoutResults(input, x as WorkoutTarget, + options.predictionOptions, options.customTargetNames, true)" :targets="targetSets[options.selectedTargetSet] ? targetSets[options.selectedTargetSet].targets : []"/> </div> diff --git a/tests/unit/components/AdvancedOptionsInput.spec.js b/tests/unit/components/AdvancedOptionsInput.spec.js @@ -68,8 +68,10 @@ test('should be correctly render race options according to props', () => { propsData: { defaultUnitSystem: 'metric', options: { - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: '_new', }, type: 'race', @@ -98,8 +100,10 @@ test('should render riegel exponent field only for supported race prediction mod propsData: { defaultUnitSystem: 'metric', options: { - model: 'AverageModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.2, + }, selectedTargetSet: '_new', }, type: 'race', @@ -161,8 +165,10 @@ test('should be correctly render workout options according to props', () => { defaultUnitSystem: 'metric', options: { customTargetNames: true, - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -190,8 +196,10 @@ test('should only show batch column label field when applicable', async () => { defaultUnitSystem: 'metric', options: { customTargetNames: true, - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -220,8 +228,10 @@ test('should only show batch column label field when applicable', async () => { defaultUnitSystem: 'metric', options: { customTargetNames: false, // disabled - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -247,8 +257,10 @@ test('should only show batch column label field when applicable', async () => { defaultUnitSystem: 'metric', options: { customTargetNames: true, // enabled - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -275,8 +287,10 @@ test('should only show batch column label field when applicable', async () => { }, defaultUnitSystem: 'metric', options: { - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -295,8 +309,10 @@ test('should pass correct props to TargetSetSelector', async () => { defaultUnitSystem: 'metric', options: { customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: 'B', }, targetSets: { @@ -359,8 +375,10 @@ test('should emit input events when options are modified', async () => { defaultUnitSystem: 'metric', options: { customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -416,26 +434,34 @@ test('should emit input events when options are modified', async () => { expect(wrapper.emitted()['update:options']).to.deep.equal([ [{ customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: 'B', }], [{ customTargetNames: true, - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: 'B', }], [{ customTargetNames: true, - model: 'CameronModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'CameronModel', + riegelExponent: 1.06, + }, selectedTargetSet: 'B', }], [{ customTargetNames: true, - model: 'CameronModel', - riegelExponent: 1.3, + predictionOptions: { + model: 'CameronModel', + riegelExponent: 1.3, + }, selectedTargetSet: 'B', }], ]); diff --git a/tests/unit/components/RaceOptionsInput.spec.js b/tests/unit/components/RaceOptionsInput.spec.js @@ -1,53 +0,0 @@ -import { test, expect } from 'vitest'; -import { shallowMount } from '@vue/test-utils'; -import RaceOptionsInput from '@/components/RaceOptionsInput.vue'; - -test('should be initialized to modelValue', () => { - // Initialize component - const wrapper = shallowMount(RaceOptionsInput, { - propsData: { - modelValue: { - model: 'PurdyPointsModel', - riegelExponent: 1.2, - } - }, - }); - - // Assert input fields are correct - expect(wrapper.find('select').element.value).to.equal('PurdyPointsModel'); - expect(wrapper.findComponent({ name: 'decimal-input' }).vm.modelValue).to.equal(1.2); -}); - -test('should emit event when inputs are modified', async () => { - // Initialize component - const wrapper = shallowMount(RaceOptionsInput, { - propsData: { - modelValue: { - model: 'AverageModel', - riegelExponent: 1.06, - }, - }, - }); - - // Update model - await wrapper.find('select').setValue('CameronModel'); - expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ - [{ - model: 'CameronModel', - riegelExponent: 1.06, - }], - ]); - - // Update Riegel exponent - await wrapper.findComponent({ name: 'decimal-input' }).setValue(1.3); - expect(wrapper.emitted()['update:modelValue']).to.deep.equal([ - [{ - model: 'CameronModel', - riegelExponent: 1.06, - }], - [{ - model: 'CameronModel', - riegelExponent: 1.3, - }], - ]); -}); diff --git a/tests/unit/core/calculators.spec.js b/tests/unit/core/calculators.spec.js @@ -14,7 +14,7 @@ describe('calculatePaceResults method', () => { type: 'distance', }; - const result = calculators.calculatePaceResults(input, target, 'metric'); + const result = calculators.calculatePaceResults(input, target, 'metric', true); expect(result).to.deep.equal({ key: '20 m', @@ -36,8 +36,8 @@ describe('calculatePaceResults method', () => { type: 'time', }; - const result1 = calculators.calculatePaceResults(input, target, 'metric'); - const result2 = calculators.calculatePaceResults(input, target, 'imperial'); + const result1 = calculators.calculatePaceResults(input, target, 'metric', true); + const result2 = calculators.calculatePaceResults(input, target, 'imperial', true); expect(result1.key).to.equal('1.61 km'); expect(result1.value).to.equal('10:00'); @@ -65,12 +65,13 @@ describe('calculateRaceResults method', () => { distanceUnit: 'kilometers', type: 'distance', }; - const options = { + const racePredictionOptions = { model: 'AverageModel', riegelExponent: 1.06, } - const result = calculators.calculateRaceResults(input, target, options, 'imperial'); + const result = calculators.calculateRaceResults(input, target, racePredictionOptions, + 'imperial', true); expect(result.key).to.equal('10 km'); expect(result.value).to.equal('41:34.80'); @@ -89,13 +90,15 @@ describe('calculateRaceResults method', () => { time: 2495, type: 'time', }; - const options = { + const racePredictionOptions = { model: 'AverageModel', riegelExponent: 1.06, - } + }; - const result1 = calculators.calculateRaceResults(input, target, options, 'metric'); - const result2 = calculators.calculateRaceResults(input, target, options, 'imperial'); + const result1 = calculators.calculateRaceResults(input, target, racePredictionOptions, + 'metric', true); + const result2 = calculators.calculateRaceResults(input, target, racePredictionOptions, + 'imperial', true); expect(result1.key).to.equal('10.00 km'); expect(result1.value).to.equal('41:35'); @@ -121,12 +124,13 @@ describe('calculateRaceResults method', () => { distanceUnit: 'kilometers', type: 'distance', }; - const options = { + const racePredictionOptions = { model: 'RiegelModel', riegelExponent: 1.12, } - const result = calculators.calculateRaceResults(input, target, options, 'imperial'); + const result = calculators.calculateRaceResults(input, target, racePredictionOptions, + 'imperial', true); expect(result.key).to.equal('5 km'); expect(result.value).to.equal('17:11.78'); @@ -167,13 +171,13 @@ describe('calculateWorkoutResults method', () => { splitUnit: 'meters', type: 'distance', }; - const options = { - customTargetNames: false, + const racePredictionOptions = { model: 'RiegelModel', riegelExponent: 1.12, } - const result = calculators.calculateWorkoutResults(input, target, options); + const result = calculators.calculateWorkoutResults(input, target, racePredictionOptions, + false, true); expect(result.key).to.equal('1000 m @ 5 km'); expect(result.value).to.equal('3:26.36'); @@ -204,21 +208,19 @@ describe('calculateWorkoutResults method', () => { type: 'distance', customName: 'my custom name', }; - const options_a = { - customTargetNames: false, - model: 'RiegelModel', - riegelExponent: 1.12, - }; - const options_b = { - customTargetNames: true, + const racePredictionOptions = { model: 'RiegelModel', riegelExponent: 1.12, }; - const result1a = calculators.calculateWorkoutResults(input, target_1, options_a); - const result1b = calculators.calculateWorkoutResults(input, target_1, options_b); - const result2a = calculators.calculateWorkoutResults(input, target_2, options_a); - const result2b = calculators.calculateWorkoutResults(input, target_2, options_b); + const result1a = calculators.calculateWorkoutResults(input, target_1, racePredictionOptions, + false, true); + const result1b = calculators.calculateWorkoutResults(input, target_1, racePredictionOptions, + true, true); + const result2a = calculators.calculateWorkoutResults(input, target_2, racePredictionOptions, + false, true); + const result2b = calculators.calculateWorkoutResults(input, target_2, racePredictionOptions, + true, true); expect(result1a.key).to.equal('1000 m @ 5 km'); expect(result1b.key).to.equal('1000 m @ 5 km'); @@ -238,13 +240,13 @@ describe('calculateWorkoutResults method', () => { splitUnit: 'miles', type: 'time', }; - const options = { - customTargetNames: false, + const racePredictionOptions = { model: 'AverageModel', riegelExponent: 1.06, } - const result = calculators.calculateWorkoutResults(input, target, options); + const result = calculators.calculateWorkoutResults(input, target, racePredictionOptions, false, + true); expect(result.key).to.equal('1 mi @ 41:35'); expect(result.value).to.equal('6:41.50'); diff --git a/tests/unit/core/racePrediction.spec.js b/tests/unit/core/racePrediction.spec.js @@ -4,66 +4,108 @@ import * as racePrediction from '@/core/racePrediction'; describe('predictTime method', () => { describe('PredictTime method', () => { test('Average Model', () => { - const riegel = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); - const cameron = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); - const purdyPoints = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); - const vo2Max = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const riegel = racePrediction.predictTime(5000, 1200, 10000, { + model: 'RiegelModel', + riegelExponent: 1.06, + }); + const cameron = racePrediction.predictTime(5000, 1200, 10000, { + model: 'CameronModel', + riegelExponent: 1.06, + }); + const purdyPoints = racePrediction.predictTime(5000, 1200, 10000, { + model: 'PurdyPointsModel', + riegelExponent: 1.06, + }); + const vo2Max = racePrediction.predictTime(5000, 1200, 10000, { + model: 'VO2MaxModel', + riegelExponent: 1.06, + }); const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; - const result = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const result = racePrediction.predictTime(5000, 1200, 10000, { + model: 'AverageModel', + riegelExponent: 1.06, + }); expect(result).to.equal(expected); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictTime(5000, 1200, 5000, 'AverageModel'); + const result = racePrediction.predictTime(5000, 1200, 5000, { + model: 'AverageModel', + riegelExponent: 1.06, + }); expect(result).to.be.closeTo(1200, 0.001); }); }); describe('Purdy Points Model', () => { test('Predictions should be approximately correct', () => { - const result = racePrediction.predictTime(5000, 1200, 10000, 'PurdyPointsModel'); + const result = racePrediction.predictTime(5000, 1200, 10000, { + model: 'PurdyPointsModel', + riegelExponent: 1.06, + }); expect(result).to.be.closeTo(2490, 1); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictTime(5000, 1200, 5000, 'PurdyPointsModel'); + const result = racePrediction.predictTime(5000, 1200, 5000, { + model: 'PurdyPointsModel', + riegelExponent: 1.06, + }); expect(result).to.be.closeTo(1200, 0.001); }); }); describe('VO2 Max Model', () => { test('Predictions should be approximately correct', () => { - const result = racePrediction.predictTime(5000, 1200, 10000, 'VO2MaxModel'); + const result = racePrediction.predictTime(5000, 1200, 10000, { + model: 'VO2MaxModel', + riegelExponent: 1.06, + }); expect(result).to.be.closeTo(2488, 1); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictTime(5000, 1200, 5000, 'VO2MaxModel'); + const result = racePrediction.predictTime(5000, 1200, 5000, { + model: 'VO2MaxModel', + riegelExponent: 1.06, + }); expect(result).to.be.closeTo(1200, 0.001); }); }); describe('Cameron Model', () => { test('Predictions should be approximately correct', () => { - const result = racePrediction.predictTime(5000, 1200, 10000, 'CameronModel'); + const result = racePrediction.predictTime(5000, 1200, 10000, { + model: 'CameronModel', + riegelExponent: 1.06, + }); expect(result).to.be.closeTo(2500, 1); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictTime(5000, 1200, 5000, 'CameronModel'); + const result = racePrediction.predictTime(5000, 1200, 5000, { + model: 'CameronModel', + riegelExponent: 1.06, + }); expect(result).to.be.closeTo(1200, 0.001); }); }); describe('Riegel Model', () => { test('Predictions should be approximately correct', () => { - const result = racePrediction.predictTime(5000, 1200, 10000, 'RiegelModel'); + const result = racePrediction.predictTime(5000, 1200, 10000, { + model: 'RiegelModel', + RiegelModel: 1.06, + }); expect(result).to.be.closeTo(2502, 1); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictTime(5000, 1200, 5000, 'RiegelModel'); + const result = racePrediction.predictTime(5000, 1200, 5000, { + model: 'RiegelModel', + RiegelModel: 1.06, + }); expect(result).to.be.closeTo(1200, 0.001); }); }); @@ -72,66 +114,108 @@ describe('predictTime method', () => { describe('predictDistance method', () => { describe('Average Model', () => { test('Predictions should be correct', () => { - const riegel = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); - const cameron = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); - const purdyPoints = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); - const vo2Max = racePrediction.predictTime(5000, 1200, 10000, 'AverageModel'); + const riegel = racePrediction.predictTime(5000, 1200, 10000, { + model: 'RiegelModel', + riegel: 1.06, + }); + const cameron = racePrediction.predictTime(5000, 1200, 10000, { + model: 'CameronModel', + riegel: 1.06, + }); + const purdyPoints = racePrediction.predictTime(5000, 1200, 10000, { + model: 'PurdyPointsModel', + riegel: 1.06, + }); + const vo2Max = racePrediction.predictTime(5000, 1200, 10000, { + model: 'VO2MaxModel', + riegel: 1.06, + }); const expected = (riegel + cameron + purdyPoints + vo2Max) / 4; - const result = racePrediction.predictDistance(1200, 5000, expected); + const result = racePrediction.predictDistance(1200, 5000, expected, { + model: 'AverageModel', + riegelExponent: 1.06, + }); expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictDistance(1200, 5000, 1200, 'AverageModel'); + const result = racePrediction.predictDistance(1200, 5000, 1200, { + model: 'AverageModel', + riegel: 1.06, + }); expect(result).to.be.closeTo(5000, 0.001); }); }); describe('Purdy Points Model', () => { test('Predictions should be approximately correct', () => { - const result = racePrediction.predictDistance(1200, 5000, 2490, 'PurdyPointsModel'); + const result = racePrediction.predictDistance(1200, 5000, 2490, { + model: 'PurdyPointsModel', + riegel: 1.06, + }); expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictDistance(1200, 5000, 1200, 'PurdyPointsModel'); + const result = racePrediction.predictDistance(1200, 5000, 1200, { + model: 'PurdyPointsModel', + riegel: 1.06, + }); expect(result).to.be.closeTo(5000, 0.001); }); }); describe('VO2 Max Model', () => { test('Predictions should be approximately correct', () => { - const result = racePrediction.predictDistance(1200, 5000, 2488, 'VO2MaxModel'); + const result = racePrediction.predictDistance(1200, 5000, 2488, { + model: 'VO2MaxModel', + riegel: 1.06, + }); expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictDistance(1200, 5000, 1200, 'VO2MaxModel'); + const result = racePrediction.predictDistance(1200, 5000, 1200, { + model: 'VO2MaxModel', + riegel: 1.06, + }); expect(result).to.be.closeTo(5000, 0.001); }); }); describe('Cameron Model', () => { test('Predictions should be approximately correct', () => { - const result = racePrediction.predictDistance(1200, 5000, 2500, 'CameronModel'); + const result = racePrediction.predictDistance(1200, 5000, 2500, { + model: 'CameronModel', + riegel: 1.06, + }); expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictDistance(1200, 5000, 1200, 'CameronModel'); + const result = racePrediction.predictDistance(1200, 5000, 1200, { + model: 'CameronModel', + riegel: 1.06, + }); expect(result).to.be.closeTo(5000, 0.001); }); }); describe('Riegel Model', () => { test('Predictions should be approximately correct', () => { - const result = racePrediction.predictDistance(1200, 5000, 2502, 'RiegelModel'); + const result = racePrediction.predictDistance(1200, 5000, 2502, { + model: 'RiegelModel', + RiegelModel: 1.06, + }); expect(result).to.be.closeTo(10000, 10); }); test('Should predict identical times for itentical distances', () => { - const result = racePrediction.predictDistance(1200, 5000, 1200, 'RiegelModel'); + const result = racePrediction.predictDistance(1200, 5000, 1200, { + model: 'RiegelModel', + RiegelModel: 1.06, + }); expect(result).to.be.closeTo(5000, 0.001); }); }); diff --git a/tests/unit/views/BatchCalculator.spec.js b/tests/unit/views/BatchCalculator.spec.js @@ -210,14 +210,18 @@ test('should load calculator options from localStorage', async () => { selectedTargetSet: 'A', })); localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: 'C', })); localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ customTargetNames: true, - model: 'RiegelModel', - riegelExponent: 1.1, + predictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.1, + }, selectedTargetSet: 'E', })); @@ -235,8 +239,10 @@ test('should load calculator options from localStorage', async () => { // Assert race calculator options are loaded await wrapper.find('select[aria-label="Calculator"]').setValue('race'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: 'C', }); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -246,8 +252,10 @@ test('should load calculator options from localStorage', async () => { await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ customTargetNames: true, - model: 'RiegelModel', - riegelExponent: 1.1, + predictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.1, + }, selectedTargetSet: 'E', }); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -301,8 +309,10 @@ test('should save calculator options to localStorage when modified', async () => } })); localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: 'D', })); localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({ @@ -316,8 +326,10 @@ test('should save calculator options to localStorage when modified', async () => })); localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ customWorkoutNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: 'F', })); @@ -335,8 +347,10 @@ test('should save calculator options to localStorage when modified', async () => // Update race calculator options await wrapper.find('select[aria-label="Calculator"]').setValue('race'); await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: 'C', }, 'options'); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -346,8 +360,10 @@ test('should save calculator options to localStorage when modified', async () => await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ customTargetNames: true, - model: 'RiegelModel', - riegelExponent: 1.1, + predictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.1, + }, selectedTargetSet: 'E', }, 'options'); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -358,14 +374,18 @@ test('should save calculator options to localStorage when modified', async () => selectedTargetSet: 'A', })); expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(JSON.stringify({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: 'C', })); expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({ customTargetNames: true, - model: 'RiegelModel', - riegelExponent: 1.1, + predictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.1, + }, selectedTargetSet: 'E', })); }); @@ -451,8 +471,10 @@ test('should pass correct input props to DoubleOutputTable', async () => { // Enable target name customization await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ customTargetNames: true, - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: '_workout_targets', }, 'options'); @@ -517,8 +539,10 @@ test('should correctly set AdvancedOptionsInput props', async () => { await wrapper.find('select[aria-label="Calculator"]').setValue('race'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.type).to.equal('race'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: '_race_targets', }); @@ -527,8 +551,10 @@ test('should correctly set AdvancedOptionsInput props', async () => { expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.type).to.equal('workout'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: '_workout_targets', }); }); @@ -537,13 +563,17 @@ test('should correctly calculate outputs', async () => { // Initialize localStorage localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ selectedTargetSet: '_race_targets', - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, })); localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ selectedTargetSet: '_workout_targets', - model: 'RiegelModel', - riegelExponent: 1.1, + predictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.1, + }, })); localStorage.setItem('running-tools.default-unit-system', '"imperial"'); diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -87,8 +87,10 @@ test('should correctly handle null target set', async () => { // Switch to invalid target set await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: 'does_not_exist', }, 'options'); @@ -97,8 +99,10 @@ test('should correctly handle null target set', async () => { // Switch to valid target set await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - model: 'AverageModel', - riegelExponent: 1.06, + predictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, selectedTargetSet: '_race_targets', }, 'options'); @@ -144,8 +148,10 @@ test('should correctly calculate results according to model options', async () = // Switch model await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - model: 'RiegelModel', // changed from the Riegel Model - riegelExponent: 1.06, + predictionOptions: { + model: 'RiegelModel', // changed from the Riegel Model + riegelExponent: 1.06, + }, selectedTargetSet: '_race_targets', }, 'options'); @@ -162,8 +168,10 @@ test('should correctly calculate results according to model options', async () = // Update Riegel Exponent await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - model: 'RiegelModel', - riegelExponent: 1, // changed from 1.06 + predictionOptions: { + model: 'RiegelModel', + riegelExponent: 1, // changed from 1.06 + }, selectedTargetSet: '_race_targets', }, 'options'); @@ -252,8 +260,10 @@ test('should load options from localStorage', async () => { 'B': targetSet2, })); localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: 'B', })); @@ -262,8 +272,10 @@ test('should load options from localStorage', async () => { // Assert data loaded expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, + predictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, selectedTargetSet: 'B', }); expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) @@ -276,15 +288,19 @@ test('should save options to localStorage when modified', async () => { // Update options await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - model: 'CameronModel', - riegelExponent: 1.30, + predictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, selectedTargetSet: 'B', }, 'options'); // Assert data saved to localStorage expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(JSON.stringify({ - model: 'CameronModel', - riegelExponent: 1.3, + predictionOptions: { + model: 'CameronModel', + riegelExponent: 1.3, + }, selectedTargetSet: 'B', })); }); diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js @@ -75,8 +75,10 @@ test('should correctly calculate results according to advanced model options', a // Update model and Riegel Exponent await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ customTargetNames: false, - model: 'RiegelModel', - riegelExponent: 1.10, + predictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.10, + }, selectedTargetSet: '_workout_targets', }, 'options');