running-tools

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

commit 6b5d8096614996c42aa3b8605320af8ea73fc667
parent 79d6bee6e188ce7e66ecf9bd6923100e5789fbaf
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat, 23 Aug 2025 14:49:24 -0700

Merge branch 'reorganize-localstorage' into dev

Diffstat:
Msrc/components/AdvancedOptionsInput.vue | 46+++++++++++++++++++++++-----------------------
Dsrc/components/RaceOptionsInput.vue | 39---------------------------------------
Msrc/core/calculators.ts | 64+++++++++++++++++++++++++++++++++++++++-------------------------
Asrc/core/migrations.ts | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/core/racePrediction.ts | 42++++++++++++++++++++++++++++--------------
Msrc/core/utils.ts | 68--------------------------------------------------------------------
Msrc/main.ts | 2+-
Msrc/views/BatchCalculator.vue | 86++++++++++++++++++++++++++++++++++++++-----------------------------------------
Msrc/views/PaceCalculator.vue | 31++++++++++++-------------------
Msrc/views/RaceCalculator.vue | 37++++++++++++++++---------------------
Msrc/views/SplitCalculator.vue | 28++++++++++++++--------------
Msrc/views/WorkoutCalculator.vue | 32+++++++++++++-------------------
Mtests/e2e/batch-calculator.spec.js | 16++++++++--------
Mtests/e2e/cross-calculator.spec.js | 252++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mtests/unit/components/AdvancedOptionsInput.spec.js | 246++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Dtests/unit/components/RaceOptionsInput.spec.js | 53-----------------------------------------------------
Mtests/unit/core/calculators.spec.js | 58++++++++++++++++++++++++++++++----------------------------
Atests/unit/core/migration.spec.js | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/core/racePrediction.spec.js | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mtests/unit/core/utils.spec.js | 107++++++++++++++++---------------------------------------------------------------
Mtests/unit/views/BatchCalculator.spec.js | 513++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mtests/unit/views/PaceCalculator.spec.js | 314++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mtests/unit/views/RaceCalculator.spec.js | 407++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mtests/unit/views/SplitCalculator.spec.js | 251++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mtests/unit/views/WorkoutCalculator.spec.js | 442++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
25 files changed, 2279 insertions(+), 1327 deletions(-)

diff --git a/src/components/AdvancedOptionsInput.vue b/src/components/AdvancedOptionsInput.vue @@ -1,7 +1,7 @@ <template> <div> Default units: - <select v-model="defaultUnitSystem" aria-label="Default units"> + <select v-model="globalOptions.defaultUnitSystem" aria-label="Default units"> <option value="imperial">Miles</option> <option value="metric">Kilometers</option> </select> @@ -9,7 +9,8 @@ <div> Target set: - <target-set-selector :setType="props.type" :default-unit-system="defaultUnitSystem" + <target-set-selector :setType="props.type" + :default-unit-system="globalOptions.defaultUnitSystem" v-model:selected-target-set="options.selectedTargetSet" v-model:target-sets="targetSets" :customWorkoutNames="props.type === Calculators.Workout ? (options as WorkoutOptions).customTargetNames : false"/> @@ -24,16 +25,17 @@ </select> </div> - <div v-if="batchOptions && props.batchInput && props.type === Calculators.Workout" + <div v-if="batchOptions && props.type === Calculators.Workout" v-show="(options as WorkoutOptions).customTargetNames"> Batch column label: - <input v-model="batchOptions.label" :placeholder="formatDistance(props.batchInput, false)" + <input v-model="batchOptions.label" :placeholder="formatDistance(batchOptions.input, false)" aria-label="Batch column label"/> </div> <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="globalOptions.racePredictionOptions.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 +45,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="globalOptions.racePredictionOptions.model == RacePredictionModels.AverageModel + || globalOptions.racePredictionOptions.model == RacePredictionModels.RiegelModel"> Riegel exponent: - <decimal-input v-model="(options as RaceOptions).riegelExponent" + <decimal-input v-model="globalOptions.racePredictionOptions.riegelExponent" aria-label="Riegel exponent" :min="1" :max="1.3" :digits="2" :step="0.01"/> (default: 1.06) </div> @@ -54,35 +56,29 @@ <script setup lang="ts"> import { Calculators } from '@/core/calculators'; -import type { BatchOptions, StandardOptions, RaceOptions, +import type { BatchOptions, GlobalOptions, PaceOptions, RaceOptions, SplitOptions, WorkoutOptions } from '@/core/calculators'; import { RacePredictionModels } from '@/core/racePrediction'; import type { TargetSets } from '@/core/targets'; -import { UnitSystems, formatDistance } from '@/core/units'; -import type { DistanceTime } from '@/core/units'; +import { formatDistance } from '@/core/units'; import DecimalInput from '@/components/DecimalInput.vue'; import TargetSetSelector from '@/components/TargetSetSelector.vue'; import useObjectModel from '@/composables/useObjectModel'; -type CalculatorOptions = StandardOptions | RaceOptions | WorkoutOptions; - -/* - * The default unit system - */ -const defaultUnitSystem = defineModel<UnitSystems>('defaultUnitSystem'); +type CalculatorOptions = PaceOptions | RaceOptions | SplitOptions | WorkoutOptions; const props = defineProps<{ /* - * The batch calculator input (if applicable, used to generate custom batch label placeholder) + * The batch calculator options (if applicable) */ - batchInput?: DistanceTime, + batchOptions?: BatchOptions, /* - * The batch calculator options (if applicable) + * The global options */ - batchOptions?: BatchOptions, + globalOptions: GlobalOptions, /* * The calculator options @@ -100,10 +96,14 @@ const props = defineProps<{ targetSets: TargetSets, }>(); -// Generate internal refs tied to options and targetSets props -const emit = defineEmits(['update:batchOptions', 'update:options', 'update:targetSets']); +// Generate internal refs tied to batchOptions, globalOptions, options and targetSets props +const emit = defineEmits([ + 'update:batchOptions', 'update:globalOptions', 'update:options', 'update:targetSets' +]); const batchOptions = useObjectModel<BatchOptions | undefined>(() => props.batchOptions, (x) => emit('update:batchOptions', x)); +const globalOptions = useObjectModel<GlobalOptions>(() => props.globalOptions, (x) => + emit('update:globalOptions', x)); const options = useObjectModel<CalculatorOptions>(() => props.options, (x) => emit('update:options', x)); const targetSets = useObjectModel<TargetSets>(() => props.targetSets, (x) => 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,10 +3,11 @@ */ 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, - getDefaultDistanceUnit, getDefaultPaceUnit } from '@/core/units'; +import { DistanceUnits, UnitSystems, convertDistance, detectDefaultUnitSystem, formatDistance, + formatDuration, formatPace, getDefaultDistanceUnit, getDefaultPaceUnit } from '@/core/units'; import type { DistanceTime } from '@/core/units'; /* @@ -34,19 +35,24 @@ export interface RaceStats { /* * The type for the options specific to each calculator */ -export interface StandardOptions { +export interface GlobalOptions { + defaultUnitSystem: UnitSystems, + racePredictionOptions: RacePredictionOptions, +}; +export interface SplitOptions { selectedTargetSet: string, -} -export interface RaceOptions extends StandardOptions { - model: racePrediction.RacePredictionModels, - riegelExponent: number, }; -export interface WorkoutOptions extends RaceOptions { +export interface PaceOptions extends SplitOptions { + input: DistanceTime, +}; +export type RaceOptions = PaceOptions; +export interface WorkoutOptions extends PaceOptions { customTargetNames: boolean, }; export interface BatchOptions { calculator: Calculators.Pace | Calculators.Race | Calculators.Workout, increment: number, + input: DistanceTime, label: string, rows: number, }; @@ -73,6 +79,10 @@ export interface TargetResult { /* * The default input and options for each calculator */ +export const defaultGlobalOptions: GlobalOptions = { + defaultUnitSystem: detectDefaultUnitSystem(), + racePredictionOptions: racePrediction.defaultRacePredictionOptions, +}; export const defaultInput: DistanceTime = { distanceValue: 5, distanceUnit: DistanceUnits.Kilometers, @@ -81,23 +91,24 @@ export const defaultInput: DistanceTime = { export const defaultBatchOptions: BatchOptions = { calculator: Calculators.Workout, increment: 15, + input: defaultInput, label: '', rows: 20, }; -export const defaultPaceOptions: StandardOptions = { +export const defaultPaceOptions: PaceOptions = { + input: defaultInput, selectedTargetSet: '_pace_targets', }; export const defaultRaceOptions: RaceOptions = { - model: racePrediction.RacePredictionModels.AverageModel, - riegelExponent: 1.06, + input: defaultInput, selectedTargetSet: '_race_targets', }; -export const defaultSplitOptions: StandardOptions = { +export const defaultSplitOptions: SplitOptions = { selectedTargetSet: '_split_targets', }; export const defaultWorkoutOptions: WorkoutOptions = { customTargetNames: false, - ...defaultRaceOptions, + input: defaultInput, selectedTargetSet: '_workout_targets', }; @@ -165,7 +176,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 +186,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 +222,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 +243,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/migrations.ts b/src/core/migrations.ts @@ -0,0 +1,182 @@ +/* + * Contains a function for migrating localStorage items after app updates + */ + +import { defaultBatchOptions, defaultGlobalOptions, defaultPaceOptions, defaultRaceOptions, + defaultSplitOptions, defaultWorkoutOptions } from '@/core/calculators'; +import { deepCopy, getLocalStorage, setLocalStorage, unsetLocalStorage } from '@/core/utils'; + +/* + * The type for string-indexable objects + */ +type dict = { + [key: string]: json, +}; + +/* + * The type for JSON-compatable values + */ +type json = dict | string | number | boolean; + +/** + * Get the value of an arbitrary property on an object + * @param {dict} obj The object + * @param {string} key The property path + * @returns {json | undefined} The value of the property + */ +function getObjProperty(obj: dict, key: string): json | undefined { + const keys = key.split("."); + while (true) { + if (keys.length === 0) { + return obj; + } else if (obj[keys[0]] === undefined) { + return undefined; + } else { + obj = obj[keys[0]] as dict; + keys.shift(); + } + } +} + +/** + * Set the value of an arbitrary property on an object + * @param {dict} obj The object + * @param {string} key The property path + * @param {json} value The new value of the property + */ +function setObjProperty(obj: dict, key: string, value: json) { + const keys = key.split("."); + while (true) { + if (keys.length === 1) { + obj[keys[0]] = value; + return; + } else if (obj[keys[0]] === undefined) { + obj[keys[0]] = {}; + obj = obj[keys[0]] as dict; + keys.shift(); + } else { + obj = obj[keys[0]] as dict; + keys.shift(); + } + } +} + +/** + * Remove an arbitrary property on an object + * @param {dict} obj The object + * @param {string} key The property path + */ +function removeObjProperty(obj: dict, key: string) { + const keys = key.split("."); + while (true) { + if (keys.length === 1) { + delete obj[keys[0]]; + return; + } else if (obj[keys[0]] === undefined) { + return; + } else { + obj = obj[keys[0]] as dict; + keys.shift(); + } + } +} + +/** + * Add a property to an existing localStorage item + * @param {string} dest The localStorage item + * @param {string} key The localStorage item property path + * @param {object | string | number | boolean} value The default property value + */ +function addProperty(dest: string, key: string, value: object | string | number | boolean) { + const dest_value = getLocalStorage<dict>(dest); + if (dest_value !== null && getObjProperty(dest_value, key) === undefined) { + setObjProperty(dest_value, key, deepCopy(value as json)); + setLocalStorage(dest, dest_value); + } +} + +/** + * Move an existing localStorage property to a new location + * @param {string} src The original localStorage item + * @param {string} src_key The original localStorage item property path + * @param {string} dest The new parent localStorage item + * @param {string} dest_key The new localStorage item property path + * @param {object} dest_default The default value of the new parent localStorage item + */ +function moveProperty(src: string, src_key: string, dest: string, dest_key: string, + dest_default: object) { + const src_value = getLocalStorage<dict>(src); + const dest_value = getLocalStorage<dict>(dest) || deepCopy(dest_default as dict); + if (src_value !== null && getObjProperty(src_value, src_key) !== undefined) { + setObjProperty(dest_value, dest_key, getObjProperty(src_value, src_key) as json); + setLocalStorage(dest, dest_value); + removeObjProperty(src_value, src_key); + setLocalStorage(src, src_value); + } + addProperty(dest, dest_key, getObjProperty(dest_default as dict, dest_key) as json); +} + +/** + * Move an existing localStorage item to a property of another localStorage item + * @param {string} src The original localStorage item + * @param {string} dest The new parent localStorage item + * @param {string} dest_key The new localStorage item property path + * @param {object} dest_default The default value of the new parent localStorage item + */ +function moveItemToProperty(src: string, dest: string, dest_key: string, dest_default: object) { + const src_value = getLocalStorage<dict>(src); + const dest_value = getLocalStorage<dict>(dest) || deepCopy(dest_default as dict); + if (src_value !== null) { + setObjProperty(dest_value, dest_key, src_value); + setLocalStorage(dest, dest_value); + unsetLocalStorage(src); + } + addProperty(dest, dest_key, (dest_default as dict)[dest_key]); +} + +/** + * Migrate outdated localStorage options + */ +export function migrateLocalStorage() { + // Move default-unit-system to global-options.defaultUnitSystem (>1.4.1) + moveItemToProperty('default-unit-system', 'global-options', 'defaultUnitSystem', + defaultGlobalOptions); + + // Move {race,workout}-calculator-options.{model,riegelExponent} into + // global-options.racePredictionOptions (>1.4.1) + moveProperty('workout-calculator-options', 'model', 'global-options', + 'racePredictionOptions.model', defaultGlobalOptions); + moveProperty('workout-calculator-options', 'riegelExponent', 'global-options', + 'racePredictionOptions.riegelExponent', defaultGlobalOptions); + moveProperty('race-calculator-options', 'model', 'global-options', + 'racePredictionOptions.model', defaultGlobalOptions); + moveProperty('race-calculator-options', 'riegelExponent', 'global-options', + 'racePredictionOptions.riegelExponent', defaultGlobalOptions); + + // Add label property to batch-calculator-options (>1.4.1) + addProperty('batch-calculator-options', 'label', defaultBatchOptions.label); + + // Add customTargetNames property to workout-calculator-options (>1.4.1) + addProperty('workout-calculator-options', 'customTargetNames', + defaultWorkoutOptions.customTargetNames); + + // Move *-calculator-input into *-calculator-options (>1.4.1) + moveItemToProperty('batch-calculator-input', 'batch-calculator-options', + 'input', defaultBatchOptions); + moveItemToProperty('pace-calculator-input', 'pace-calculator-options', + 'input', defaultPaceOptions); + moveItemToProperty('race-calculator-input', 'race-calculator-options', + 'input', defaultRaceOptions); + moveItemToProperty('workout-calculator-input', 'workout-calculator-options', + 'input', defaultWorkoutOptions); + + // Move *-calculator-target-set into *-calculator-options (>1.4.1) + moveItemToProperty('pace-calculator-target-set', 'pace-calculator-options', + 'selectedTargetSet', defaultPaceOptions); + moveItemToProperty('race-calculator-target-set', 'race-calculator-options', + 'selectedTargetSet', defaultRaceOptions); + moveItemToProperty('split-calculator-target-set', 'split-calculator-options', + 'selectedTargetSet', defaultSplitOptions); + moveItemToProperty('workout-calculator-target-set', 'workout-calculator-options', + 'selectedTargetSet', defaultWorkoutOptions); +} diff --git a/src/core/racePrediction.ts b/src/core/racePrediction.ts @@ -14,6 +14,22 @@ export enum RacePredictionModels { }; /* + * The type for race prediction options + */ +export interface RacePredictionOptions { + model: RacePredictionModels, + riegelExponent: number, +}; + +/* + * The default race prediction options + */ +export const defaultRacePredictionOptions = { + model: RacePredictionModels.AverageModel, + riegelExponent: 1.06, +}; + +/* * The type for internal variables used by the Purdy Points race prediction model */ interface PurdyPointsVariables { @@ -438,22 +454,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 +479,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/core/utils.ts b/src/core/utils.ts @@ -2,8 +2,6 @@ * Contains utility functions for handling nested objects and interacting with localStorage */ -import { defaultRaceOptions, defaultWorkoutOptions } from '@/core/calculators'; - // The global localStorage prefix const LocalStoragePrefix = 'running-tools'; @@ -55,69 +53,3 @@ export function setLocalStorage<Type>(key: string, value: Type) { export function unsetLocalStorage(key: string) { localStorage.removeItem(`${LocalStoragePrefix}.${key}`); } - -/** - * Migrate outdated localStorage options - */ -export function migrateLocalStorage() { - /* eslint-disable @typescript-eslint/no-explicit-any */ - - // Add label property to batch-calculator-options (>1.4.1) - const batchOptions = getLocalStorage<any>('batch-calculator-options'); - if (batchOptions !== null && batchOptions.label === undefined) { - batchOptions.label = ''; - setLocalStorage('batch-calculator-options', batchOptions); - } - - // Move pace-calculator-target-set into new pace-calculator-options (>1.4.1) - const paceSelectedTargetSet = getLocalStorage<string>('pace-calculator-target-set'); - if (paceSelectedTargetSet !== null) { - const paceOptions = { selectedTargetSet: paceSelectedTargetSet }; - setLocalStorage('pace-calculator-options', paceOptions); - unsetLocalStorage('pace-calculator-target-set'); - } - - // Move race-calculator-target-set into race-calculator-options (>1.4.1) - const raceSelectedTargetSet = getLocalStorage<string>('race-calculator-target-set'); - const raceOptions = getLocalStorage<any>('race-calculator-options') - || deepCopy(defaultRaceOptions); - if (raceSelectedTargetSet !== null) { - raceOptions.selectedTargetSet = raceSelectedTargetSet; - setLocalStorage('race-calculator-options', raceOptions); - unsetLocalStorage('race-calculator-target-set'); - } - if (raceOptions !== null && raceOptions.selectedTargetSet === undefined) { - raceOptions.selectedTargetSet = defaultRaceOptions.selectedTargetSet; - setLocalStorage('race-calculator-options', raceOptions); - } - - // Move split-calculator-target-set into new split-calculator-options (>1.4.1) - const splitSelectedTargetSet = getLocalStorage<string>('split-calculator-target-set'); - if (splitSelectedTargetSet !== null) { - const splitOptions = { selectedTargetSet: splitSelectedTargetSet }; - setLocalStorage('split-calculator-options', splitOptions); - unsetLocalStorage('split-calculator-target-set'); - } - - // Move workout-calculator-target-set into workout-calculator-options (>1.4.1) - const workoutSelectedTargetSet = getLocalStorage<string>('workout-calculator-target-set'); - const workoutOptions = getLocalStorage<any>('workout-calculator-options') - || deepCopy(defaultWorkoutOptions); - if (workoutSelectedTargetSet !== null) { - workoutOptions.selectedTargetSet = workoutSelectedTargetSet; - setLocalStorage('workout-calculator-options', workoutOptions); - unsetLocalStorage('workout-calculator-target-set'); - } - if (workoutOptions !== null && workoutOptions.selectedTargetSet === undefined) { - workoutOptions.selectedTargetSet = defaultWorkoutOptions.selectedTargetSet; - setLocalStorage('workout-calculator-options', workoutOptions); - } - - // Add customTargetNames property to workout-calculator-options (>1.4.1) - if (workoutOptions.customTargetNames === undefined) { - workoutOptions.customTargetNames = false; - setLocalStorage('workout-calculator-options', workoutOptions); - } - - /* eslint-enable @typescript-eslint/no-explicit-any */ -} diff --git a/src/main.ts b/src/main.ts @@ -2,7 +2,7 @@ import { createApp } from 'vue'; import App from '@/App.vue'; import router from '@/router'; -import { migrateLocalStorage } from '@/core/utils'; +import { migrateLocalStorage } from '@/core/migrations'; import '@/assets/global.css'; diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue @@ -2,20 +2,20 @@ <div class="calculator"> <h2>Batch Input</h2> <div class="input"> - <pace-input v-model="input" aria-label="Input"/> + <pace-input v-model="batchOptions.input" aria-label="Input"/> </div> <h2>Batch Options</h2> <div class="input"> <div> Increment: - <time-input v-model="options.increment" label="Duration increment" :show-hours="false"/> + <time-input v-model="batchOptions.increment" label="Duration increment" :show-hours="false"/> &times; - <integer-input v-model="options.rows" min="1" aria-label="Number of rows"/> + <integer-input v-model="batchOptions.rows" min="1" aria-label="Number of rows"/> </div> <div> Calculator: - <select aria-label="Calculator" v-model="options.calculator"> + <select aria-label="Calculator" v-model="batchOptions.calculator"> <option :value="calculators.Calculators.Pace">Pace Calculator</option> <option :value="calculators.Calculators.Race">Race Calculator</option> <option :value="calculators.Calculators.Workout">Workout Calculator</option> @@ -27,9 +27,9 @@ <summary> <h2>Advanced Options</h2> </summary> - <advanced-options-input :batch-input="input" v-model:batch-options="options" - v-model:defaultUnitSystem="defaultUnitSystem" v-model:options="calcOptions" - v-model:targetSets="targetSets" :type="options.calculator"/> + <advanced-options-input v-model:batch-options="batchOptions" + v-model:globalOptions="globalOptions" v-model:options="calcOptions" + v-model:targetSets="targetSets" :type="batchOptions.calculator"/> </details> <h2>Batch Results</h2> @@ -44,10 +44,10 @@ import { computed } from 'vue'; import * as calculators from '@/core/calculators'; -import type { BatchOptions, RaceOptions, StandardOptions, TargetResult, +import type { BatchOptions, GlobalOptions, PaceOptions, RaceOptions, TargetResult, WorkoutOptions } from '@/core/calculators'; import * as targetUtils from '@/core/targets'; -import { UnitSystems, detectDefaultUnitSystem, formatDistance } from '@/core/units'; +import { formatDistance } from '@/core/units'; import type { Distance, DistanceTime } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; @@ -59,20 +59,25 @@ import TimeInput from '@/components/TimeInput.vue'; import useStorage from '@/composables/useStorage'; /* - * The input pace + * The global options */ -const input = useStorage<DistanceTime>('batch-calculator-input', calculators.defaultInput); +const globalOptions = useStorage<GlobalOptions>('global-options', calculators.defaultGlobalOptions); /* - * The batch input options + * The batch calculator options */ -const options = useStorage<BatchOptions>('batch-calculator-options', +const batchOptions = useStorage<BatchOptions>('batch-calculator-options', calculators.defaultBatchOptions); /* - * The default unit system + * The options for each calculator */ -const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); +const paceOptions = useStorage<PaceOptions>('pace-calculator-options', + calculators.defaultPaceOptions); +const raceOptions = useStorage<RaceOptions>('race-calculator-options', + calculators.defaultRaceOptions); +const workoutOptions = useStorage<WorkoutOptions>('workout-calculator-options', + calculators.defaultWorkoutOptions); /* * The target sets for each calculator @@ -85,21 +90,11 @@ const workoutTargetSets = useStorage<targetUtils.WorkoutTargetSets>( 'workout-calculator-target-sets', targetUtils.defaultWorkoutTargetSets); /* - * The options for each calculator - */ -const paceOptions = useStorage<StandardOptions>('pace-calculator-options', - calculators.defaultPaceOptions); -const raceOptions = useStorage<RaceOptions>('race-calculator-options', - calculators.defaultRaceOptions); -const workoutOptions = useStorage<WorkoutOptions>('workout-calculator-options', - calculators.defaultWorkoutOptions); - -/* * The input distance */ const inputDistance = computed<Distance>(() => ({ - distanceValue: input.value.distanceValue, - distanceUnit: input.value.distanceUnit, + distanceValue: batchOptions.value.input.distanceValue, + distanceUnit: batchOptions.value.input.distanceUnit, })); /* @@ -107,8 +102,8 @@ const inputDistance = computed<Distance>(() => ({ */ const inputTimes = computed<Array<number>>(() => { const results = []; - for (let i = 0; i < options.value.rows; i++) { - results.push(input.value.time + options.value.increment * i); + for (let i = 0; i < batchOptions.value.rows; i++) { + results.push(batchOptions.value.input.time + batchOptions.value.increment * i); } return results; }); @@ -118,7 +113,7 @@ const inputTimes = computed<Array<number>>(() => { */ const targetSets = computed<targetUtils.TargetSets>({ get: () => { - switch (options.value.calculator) { + switch (batchOptions.value.calculator) { case (calculators.Calculators.Pace): { return paceTargetSets.value; } @@ -132,7 +127,7 @@ const targetSets = computed<targetUtils.TargetSets>({ } }, set: (newValue: targetUtils.TargetSets) => { - switch (options.value.calculator) { + switch (batchOptions.value.calculator) { case (calculators.Calculators.Pace): { paceTargetSets.value = newValue as targetUtils.StandardTargetSets; break; @@ -153,9 +148,9 @@ const targetSets = computed<targetUtils.TargetSets>({ /* * The options for the current calculator */ -const calcOptions = computed<StandardOptions | RaceOptions | WorkoutOptions>({ +const calcOptions = computed<PaceOptions | RaceOptions | WorkoutOptions>({ get: () => { - switch (options.value.calculator) { + switch (batchOptions.value.calculator) { case (calculators.Calculators.Pace): { return paceOptions.value; } @@ -168,10 +163,10 @@ const calcOptions = computed<StandardOptions | RaceOptions | WorkoutOptions>({ } } }, - set: (newValue: StandardOptions | RaceOptions | WorkoutOptions) => { - switch(options.value.calculator) { + set: (newValue: PaceOptions | RaceOptions | WorkoutOptions) => { + switch(batchOptions.value.calculator) { case (calculators.Calculators.Pace): { - paceOptions.value = newValue as StandardOptions; + paceOptions.value = newValue as PaceOptions; break; } case (calculators.Calculators.Race): { @@ -191,18 +186,19 @@ const calcOptions = computed<StandardOptions | RaceOptions | WorkoutOptions>({ * The appropriate calculate_results function for the current calculator */ const calculateResult = computed<(x: DistanceTime, y: targetUtils.Target) => TargetResult>(() => { - switch(options.value.calculator) { + switch(batchOptions.value.calculator) { case (calculators.Calculators.Pace): { - return (x,y) => calculators.calculatePaceResults(x, y, defaultUnitSystem.value, false); + return (x,y) => calculators.calculatePaceResults(x, y, globalOptions.value.defaultUnitSystem, + false); } case (calculators.Calculators.Race): { - return (x,y) => calculators.calculateRaceResults(x, y, raceOptions.value, - defaultUnitSystem.value, false); + return (x,y) => calculators.calculateRaceResults(x, y, + globalOptions.value.racePredictionOptions, globalOptions.value.defaultUnitSystem, false); } default: case (calculators.Calculators.Workout): { return (x,y) => calculators.calculateWorkoutResults(x, y as targetUtils.WorkoutTarget, - workoutOptions.value, false); + globalOptions.value.racePredictionOptions, workoutOptions.value.customTargetNames, false); } } }); @@ -211,11 +207,11 @@ const calculateResult = computed<(x: DistanceTime, y: targetUtils.Target) => Tar * The label to render for the batch column */ const batchColumnLabel = computed<string>(() => { - if (options.value.calculator == calculators.Calculators.Workout && - (calcOptions.value as WorkoutOptions).customTargetNames && options.value.label) { - return options.value.label; + if (batchOptions.value.calculator == calculators.Calculators.Workout && + (calcOptions.value as WorkoutOptions).customTargetNames && batchOptions.value.label) { + return batchOptions.value.label; } else { - return formatDistance(input.value, false); + return formatDistance(batchOptions.value.input, false); } }); </script> diff --git a/src/views/PaceCalculator.vue b/src/views/PaceCalculator.vue @@ -2,33 +2,31 @@ <div class="calculator"> <h2>Input Pace</h2> <div class="input"> - <pace-input v-model="input"/> + <pace-input v-model="paceOptions.input"/> </div> <details> <summary> <h2>Advanced Options</h2> </summary> - <advanced-options-input v-model:defaultUnitSystem="defaultUnitSystem" - v-model:options="options" v-model:targetSets="targetSets" :type="Calculators.Pace"/> + <advanced-options-input v-model:globalOptions="globalOptions" + v-model:options="paceOptions" v-model:targetSets="targetSets" :type="Calculators.Pace"/> </details> <h2>Equivalent Paces</h2> <single-output-table class="output" :calculate-result="x => - calculatePaceResults(input, x, defaultUnitSystem, true)" - :targets="targetSets[options.selectedTargetSet] ? - targetSets[options.selectedTargetSet].targets : []"/> + calculatePaceResults(paceOptions.input, x, globalOptions.defaultUnitSystem, true)" + :targets="targetSets[paceOptions.selectedTargetSet] ? + targetSets[paceOptions.selectedTargetSet].targets : []"/> </div> </template> <script setup lang="ts"> -import { Calculators, calculatePaceResults, defaultInput, +import { Calculators, calculatePaceResults, defaultGlobalOptions, defaultPaceOptions } from '@/core/calculators'; -import type { StandardOptions } from '@/core/calculators'; +import type { GlobalOptions, PaceOptions } from '@/core/calculators'; import { defaultPaceTargetSets } from '@/core/targets'; import type { StandardTargetSets } from '@/core/targets'; -import { UnitSystems, detectDefaultUnitSystem } from '@/core/units'; -import type { DistanceTime } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import PaceInput from '@/components/PaceInput.vue'; @@ -37,19 +35,14 @@ import SingleOutputTable from '@/components/SingleOutputTable.vue'; import useStorage from '@/composables/useStorage'; /* - * The input pace + * The global options */ -const input = useStorage<DistanceTime>('pace-calculator-input', defaultInput); +const globalOptions = useStorage<GlobalOptions>('global-options', defaultGlobalOptions); /* - * The default unit system + * The pace calculator options */ -const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); - -/* - * The current selected target set - */ -const options = useStorage<StandardOptions>('pace-calculator-options', defaultPaceOptions); +const paceOptions = useStorage<PaceOptions>('pace-calculator-options', defaultPaceOptions); /* * The target sets diff --git a/src/views/RaceCalculator.vue b/src/views/RaceCalculator.vue @@ -2,7 +2,7 @@ <div class="calculator"> <h2>Input Race Result</h2> <div class="input"> - <pace-input v-model="input" label="Input race"/> + <pace-input v-model="raceOptions.input" label="Input race"/> </div> <details> @@ -26,28 +26,28 @@ <summary> <h2>Advanced Options</h2> </summary> - <advanced-options-input v-model:defaultUnitSystem="defaultUnitSystem" - v-model:options="options" v-model:targetSets="targetSets" :type="Calculators.Race"/> + <advanced-options-input v-model:globalOptions="globalOptions" + v-model:options="raceOptions" v-model:targetSets="targetSets" :type="Calculators.Race"/> </details> <h2>Equivalent Race Results</h2> - <single-output-table class="output" show-pace - :calculate-result="x => calculateRaceResults(input, x, options, defaultUnitSystem, true)" - :targets="targetSets[options.selectedTargetSet] ? - targetSets[options.selectedTargetSet].targets : []"/> + <single-output-table class="output" show-pace :calculate-result="x => + calculateRaceResults(raceOptions.input, x, globalOptions.racePredictionOptions, + globalOptions.defaultUnitSystem, true)" + :targets="targetSets[raceOptions.selectedTargetSet] ? + targetSets[raceOptions.selectedTargetSet].targets : []"/> </div> </template> <script setup lang="ts"> import { computed } from 'vue'; -import { Calculators, calculateRaceResults, calculateRaceStats, defaultInput, +import { Calculators, calculateRaceResults, calculateRaceStats, defaultGlobalOptions, defaultRaceOptions } from '@/core/calculators'; -import type { RaceOptions, RaceStats } from '@/core/calculators'; +import type { GlobalOptions, RaceOptions, RaceStats } from '@/core/calculators'; import { defaultRaceTargetSets } from '@/core/targets'; import type { StandardTargetSets } from '@/core/targets'; -import { UnitSystems, detectDefaultUnitSystem, formatNumber } from '@/core/units'; -import type { DistanceTime } from '@/core/units'; +import { formatNumber } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import PaceInput from '@/components/PaceInput.vue'; @@ -56,22 +56,17 @@ import SingleOutputTable from '@/components/SingleOutputTable.vue'; import useStorage from '@/composables/useStorage'; /* - * The input race + * The global options */ -const input = useStorage<DistanceTime>('race-calculator-input', defaultInput); - -/* - * The default unit system - */ -const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); +const globalOptions = useStorage<GlobalOptions>('global-options', defaultGlobalOptions); /* * The race calculator options */ -const options = useStorage<RaceOptions>('race-calculator-options', defaultRaceOptions); +const raceOptions = useStorage<RaceOptions>('race-calculator-options', defaultRaceOptions); /* - * The target sets + * The race calculator target sets */ const targetSets = useStorage<StandardTargetSets>('race-calculator-target-sets', defaultRaceTargetSets); @@ -79,7 +74,7 @@ const targetSets = useStorage<StandardTargetSets>('race-calculator-target-sets', /* * The statistics for the current input race */ -const raceStats = computed<RaceStats>(() => calculateRaceStats(input.value)); +const raceStats = computed<RaceStats>(() => calculateRaceStats(raceOptions.value.input)); </script> <style scoped> diff --git a/src/views/SplitCalculator.vue b/src/views/SplitCalculator.vue @@ -1,12 +1,13 @@ <template> <div class="calculator"> <div class="input"> - <advanced-options-input v-model:defaultUnitSystem="defaultUnitSystem" - v-model:options="options" v-model:targetSets="targetSets" :type="Calculators.Split"/> + <advanced-options-input v-model:globalOptions="globalOptions" + v-model:options="splitOptions" v-model:targetSets="targetSets" :type="Calculators.Split"/> </div> <div class="output"> - <split-output-table :default-unit-system="defaultUnitSystem" v-model="targetSet"/> + <split-output-table :default-unit-system="globalOptions.defaultUnitSystem" + v-model="targetSet"/> </div> </div> </template> @@ -14,11 +15,10 @@ <script setup lang="ts"> import { computed } from 'vue'; -import { Calculators, defaultSplitOptions } from '@/core/calculators'; -import type { StandardOptions } from '@/core/calculators'; +import { Calculators, defaultGlobalOptions, defaultSplitOptions } from '@/core/calculators'; +import type { GlobalOptions, SplitOptions } from '@/core/calculators'; import { defaultSplitTargetSets } from '@/core/targets'; import type { SplitTargetSets } from '@/core/targets'; -import { UnitSystems, detectDefaultUnitSystem } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import SplitOutputTable from '@/components/SplitOutputTable.vue'; @@ -26,17 +26,17 @@ import SplitOutputTable from '@/components/SplitOutputTable.vue'; import useStorage from '@/composables/useStorage'; /* - * The default unit system + * The global options */ -const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); +const globalOptions = useStorage<GlobalOptions>('global-options', defaultGlobalOptions); /* * The split calculator options */ -const options = useStorage<StandardOptions>('split-calculator-options', defaultSplitOptions); +const splitOptions = useStorage<SplitOptions>('split-calculator-options', defaultSplitOptions); /* - * The default output targets + * The split calculator target sets */ const targetSets = useStorage<SplitTargetSets>('split-calculator-target-sets', defaultSplitTargetSets); @@ -46,15 +46,15 @@ const targetSets = useStorage<SplitTargetSets>('split-calculator-target-sets', */ const targetSet = computed({ get: () => { - if (targetSets.value[options.value.selectedTargetSet]) { - return targetSets.value[options.value.selectedTargetSet].targets + if (targetSets.value[splitOptions.value.selectedTargetSet]) { + return targetSets.value[splitOptions.value.selectedTargetSet].targets } else { return [] } }, set: (newValue) => { - if (targetSets.value[options.value.selectedTargetSet]) { - targetSets.value[options.value.selectedTargetSet].targets = newValue; + if (targetSets.value[splitOptions.value.selectedTargetSet]) { + targetSets.value[splitOptions.value.selectedTargetSet].targets = newValue; } }, }); diff --git a/src/views/WorkoutCalculator.vue b/src/views/WorkoutCalculator.vue @@ -2,33 +2,32 @@ <div class="calculator"> <h2>Input Race Result</h2> <div class="input"> - <pace-input v-model="input" label="Input race"/> + <pace-input v-model="workoutOptions.input" label="Input race"/> </div> <details> <summary> <h2>Advanced Options</h2> </summary> - <advanced-options-input v-model:defaultUnitSystem="defaultUnitSystem" - v-model:options="options" v-model:targetSets="targetSets" :type="Calculators.Workout"/> + <advanced-options-input v-model:globalOptions="globalOptions" + v-model:options="workoutOptions" v-model:targetSets="targetSets" :type="Calculators.Workout"/> </details> <h2>Workout Splits</h2> - <single-output-table class="output" - :calculate-result="x => calculateWorkoutResults(input, x as WorkoutTarget, options, true)" - :targets="targetSets[options.selectedTargetSet] ? - targetSets[options.selectedTargetSet].targets : []"/> + <single-output-table class="output" :calculate-result="x => + calculateWorkoutResults(workoutOptions.input, x as WorkoutTarget, + globalOptions.racePredictionOptions, workoutOptions.customTargetNames, true)" + :targets="targetSets[workoutOptions.selectedTargetSet] ? + targetSets[workoutOptions.selectedTargetSet].targets : []"/> </div> </template> <script setup lang="ts"> -import { Calculators, calculateWorkoutResults, defaultInput, +import { Calculators, calculateWorkoutResults, defaultGlobalOptions, defaultWorkoutOptions } from '@/core/calculators'; -import type { WorkoutOptions } from '@/core/calculators'; +import type { GlobalOptions, WorkoutOptions } from '@/core/calculators'; import { defaultWorkoutTargetSets } from '@/core/targets'; import type { WorkoutTarget, WorkoutTargetSets } from '@/core/targets'; -import { UnitSystems, detectDefaultUnitSystem } from '@/core/units'; -import type { DistanceTime } from '@/core/units'; import AdvancedOptionsInput from '@/components/AdvancedOptionsInput.vue'; import PaceInput from '@/components/PaceInput.vue'; @@ -37,19 +36,14 @@ import SingleOutputTable from '@/components/SingleOutputTable.vue'; import useStorage from '@/composables/useStorage'; /* - * The input race + * The global options */ -const input = useStorage<DistanceTime>('workout-calculator-input', defaultInput); - -/* - * The default unit system - */ -const defaultUnitSystem = useStorage<UnitSystems>('default-unit-system', detectDefaultUnitSystem()); +const globalOptions = useStorage<GlobalOptions>('global-options', defaultGlobalOptions); /* * The race prediction options */ -const options = useStorage<WorkoutOptions>('workout-calculator-options', defaultWorkoutOptions); +const workoutOptions = useStorage<WorkoutOptions>('workout-calculator-options', defaultWorkoutOptions); /* * The target sets diff --git a/tests/e2e/batch-calculator.spec.js b/tests/e2e/batch-calculator.spec.js @@ -105,15 +105,15 @@ test('Batch calculator', async ({ page }) => { await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m'); await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); - await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:15'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:24'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); - await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:44'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:56'); await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row')).toHaveCount(16); // Change Riegel exponent - await expect(page.getByLabel('Prediction model')).toHaveValue('AverageModel'); + await expect(page.getByLabel('Prediction model')).toHaveValue('RiegelModel'); await page.getByLabel('Riegel Exponent').fill('1.12'); // Assert race results are correct @@ -124,7 +124,7 @@ test('Batch calculator', async ({ page }) => { await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:12'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); - await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:40'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:42'); await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row')).toHaveCount(16); } @@ -141,7 +141,7 @@ test('Batch calculator', async ({ page }) => { await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:12'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); - await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:40'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:42'); await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row')).toHaveCount(16); } @@ -164,7 +164,7 @@ test('Batch calculator', async ({ page }) => { await expect(page.getByRole('row')).toHaveCount(16); } - // Assert workout results are correct (inputs and options not reset) + // Assert workout results are correct (inputs not reset, but updated options are used) { await page.getByLabel('Calculator').selectOption('Workout Calculator'); await expect(page.getByLabel('Target name customization')).toHaveValue("true"); @@ -172,10 +172,10 @@ test('Batch calculator', async ({ page }) => { 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); await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); - await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:41'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:45'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); - await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:17'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:22'); await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row')).toHaveCount(16); } diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js @@ -110,9 +110,9 @@ 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 and enable target name customization + // Change riegel exponent and enable target name customization await page.getByText('Advanced Options').click(); - await page.getByLabel('Prediction model').selectOption('V̇O₂ Max Model'); + await page.getByLabel('Riegel Exponent').fill('1.12'); await page.getByLabel('Target name customization').selectOption('Enabled'); // Change default units (should update on other calculators too) @@ -125,15 +125,15 @@ test('Cross-calculator', async ({ page }) => { await page.getByRole('link', { name: 'Back' }).click(); await page.getByRole('button', { name: 'Batch Calculator' }).click(); - // Assert pace results are correct (inputs and options not reset) + // Assert race results are correct (inputs and options not reset) 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'); await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); - await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:24'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:12'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); - await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:56'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:42'); await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row')).toHaveCount(16); @@ -157,10 +157,10 @@ test('Cross-calculator', async ({ page }) => { 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); await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); - await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:42'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:45'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); - await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:17'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:22'); await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row')).toHaveCount(16); @@ -182,8 +182,8 @@ test('Cross-calculator', async ({ page }) => { await page.getByRole('button', { name: 'Race Calculator' }).click(); // Assert race predictions are correct (input race not resset and new prediction model loaded) - await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '5:02.17' + '3:08 / km'); - await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:44.87' + '3:21 / km'); + await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:49.86' + '3:00 / km'); + await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '17:11.78' + '3:26 / km'); await expect(page.getByRole('row')).toHaveCount(17); // Return to split calculator @@ -212,43 +212,51 @@ test('Cross-calculator', async ({ page }) => { // Assert workout splits are correct (input race and prediction model not reset) await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:14.81'); - await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:53.56'); + await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '6:30.40'); await expect(page.getByRole('row')).toHaveCount(5); } // Assert localStorage entries are correct { - // Assert general localStorage entries are correct - expect(await page.evaluate(() => localStorage.length)).toEqual(16); - expect(await page.evaluate(() => localStorage.getItem('running-tools.default-unit-system'))) - .toEqual(JSON.stringify('metric')); + // Assert global localStorage entries are correct + expect(await page.evaluate(() => localStorage.length)).toEqual(12); + expect(await page.evaluate(() => localStorage.getItem('running-tools.global-options'))) + .toEqual(JSON.stringify({ + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.12, + }, + })); // Assert localStorage entries for the batch calculator are correct expect(await page.evaluate(() => - localStorage.getItem('running-tools.batch-calculator-input'))).toEqual(JSON.stringify({ - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - })); - expect(await page.evaluate(() => localStorage.getItem('running-tools.batch-calculator-options'))).toEqual(JSON.stringify({ calculator: 'race', increment: 10, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }, label: '', rows: 15, })); // Assert localStorage entries for the pace calculator are correct - expect(await page.evaluate(() => - localStorage.getItem('running-tools.pace-calculator-input'))).toEqual(JSON.stringify({ - distanceValue: 2, - distanceUnit: 'miles', - time: 930, - })); const paceCalculatorKey = parseInt(JSON.parse(await page.evaluate(() => localStorage.getItem('running-tools.pace-calculator-options'))).selectedTargetSet); expect(paceCalculatorKey - parseInt(Date.now().toString())).toBeLessThan(100000); expect(await page.evaluate(() => + localStorage.getItem('running-tools.pace-calculator-options'))).toEqual(JSON.stringify({ + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 930, + }, + selectedTargetSet: paceCalculatorKey.toString(), + })); + expect(await page.evaluate(() => localStorage.getItem('running-tools.pace-calculator-target-sets'))).toEqual(JSON.stringify({ _pace_targets: { name: 'Common Pace Targets', @@ -297,15 +305,12 @@ test('Cross-calculator', async ({ page }) => { // Assert localStorage entries for the race calculator are correct expect(await page.evaluate(() => - localStorage.getItem('running-tools.race-calculator-input'))).toEqual(JSON.stringify({ - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - })); - expect(await page.evaluate(() => localStorage.getItem('running-tools.race-calculator-options'))).toEqual(JSON.stringify({ - model: 'RiegelModel', - riegelExponent: 1.06, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }, selectedTargetSet: '_race_targets', })); expect(await page.evaluate(() => @@ -375,16 +380,13 @@ test('Cross-calculator', async ({ page }) => { // Assert localStorage entries for the workout calculator are correct expect(await page.evaluate(() => - localStorage.getItem('running-tools.workout-calculator-input'))).toEqual(JSON.stringify({ - distanceValue: 1, - distanceUnit: 'miles', - time: 301, - })); - expect(await page.evaluate(() => localStorage.getItem('running-tools.workout-calculator-options'))).toEqual(JSON.stringify({ customTargetNames: true, - model: 'VO2MaxModel', - riegelExponent: 1.06, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 301, + }, selectedTargetSet: '_workout_targets', })); expect(await page.evaluate(() => @@ -416,19 +418,19 @@ test('Cross-calculator', async ({ page }) => { // Reload app and assert the updated options are loaded // Identical to the previous "go back and assert the options are not resset" section { - // Reload app and go to batch calculator - await page.goto('/'); + // Return to batch calculator + await page.getByRole('link', { name: 'Back' }).click(); await page.getByRole('button', { name: 'Batch Calculator' }).click(); - // Assert pace results are correct (inputs and options not reset) + // Assert race results are correct (inputs and options not reset) 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'); await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); - await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:24'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:12'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); - await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:56'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('2:42'); await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row')).toHaveCount(16); @@ -452,10 +454,10 @@ test('Cross-calculator', async ({ page }) => { 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); await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); - await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:42'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:45'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); - await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:17'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:22'); await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row')).toHaveCount(16); @@ -477,8 +479,8 @@ test('Cross-calculator', async ({ page }) => { await page.getByRole('button', { name: 'Race Calculator' }).click(); // Assert race predictions are correct (input race not resset and new prediction model loaded) - await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '5:02.17' + '3:08 / km'); - await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:44.87' + '3:21 / km'); + await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '4:49.86' + '3:00 / km'); + await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '17:11.78' + '3:26 / km'); await expect(page.getByRole('row')).toHaveCount(17); // Return to split calculator @@ -507,7 +509,7 @@ test('Cross-calculator', async ({ page }) => { // Assert workout splits are correct (input race and prediction model not reset) await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:14.81'); - await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:53.56'); + await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '6:30.40'); await expect(page.getByRole('row')).toHaveCount(5); } }); @@ -724,34 +726,44 @@ test('v1.4.1 Migration', async ({ page }) => { { // Reload the app and assert general localStorage entries are correct await page.goto('/'); - expect(await page.evaluate(() => localStorage.length)).toEqual(16); - expect(await page.evaluate(() => localStorage.getItem('running-tools.default-unit-system'))) - .toEqual(JSON.stringify('metric')); + expect(await page.evaluate(() => localStorage.length)).toEqual(12); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.default-unit-system'))).toBeNull(); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.global-options'))).toEqual(JSON.stringify({ + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.06, + }, + })); // Assert localStorage entries for the batch calculator are correct expect(await page.evaluate(() => - localStorage.getItem('running-tools.batch-calculator-input'))).toEqual(JSON.stringify({ - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - })); + localStorage.getItem('running-tools.batch-calculator-input'))).toBeNull(); expect(await page.evaluate(() => localStorage.getItem('running-tools.batch-calculator-options'))).toEqual(JSON.stringify({ calculator: 'race', increment: 10, rows: 15, label: '', + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }, })); // Assert localStorage entries for the pace calculator are correct expect(await page.evaluate(() => - localStorage.getItem('running-tools.pace-calculator-input'))).toEqual(JSON.stringify({ - distanceValue: 2, - distanceUnit: 'miles', - time: 930, - })); + localStorage.getItem('running-tools.pace-calculator-input'))).toBeNull(); expect(await page.evaluate(() => localStorage.getItem('running-tools.pace-calculator-options'))) .toEqual(JSON.stringify({ + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 930, + }, selectedTargetSet: '123456789', })); expect(await page.evaluate(() => @@ -805,15 +817,14 @@ test('v1.4.1 Migration', async ({ page }) => { // Assert localStorage entries for the race calculator are correct expect(await page.evaluate(() => - localStorage.getItem('running-tools.race-calculator-input'))).toEqual(JSON.stringify({ - distanceValue: 2, - distanceUnit: 'miles', - time: 630, - })); + localStorage.getItem('running-tools.race-calculator-input'))).toBeNull(); expect(await page.evaluate(() => localStorage.getItem('running-tools.race-calculator-options'))) .toEqual(JSON.stringify({ - model: 'RiegelModel', - riegelExponent: 1.06, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }, selectedTargetSet: '_race_targets', })); expect(await page.evaluate(() => @@ -849,6 +860,8 @@ test('v1.4.1 Migration', async ({ page }) => { selectedTargetSet: '_split_targets', })); expect(await page.evaluate(() => + localStorage.getItem('running-tools.split-calculator-target-set'))).toBeNull(); + expect(await page.evaluate(() => localStorage.getItem('running-tools.split-calculator-target-sets'))).toEqual(JSON.stringify({ _split_targets: { name: '5K 1600m Splits', @@ -859,8 +872,6 @@ test('v1.4.1 Migration', async ({ page }) => { ], }, })); - expect(await page.evaluate(() => - localStorage.getItem('running-tools.split-calculator-target-set'))).toBeNull(); // Assert localStorage entries for the unit calculator are correct expect(await page.evaluate(() => localStorage.getItem('running-tools.unit-calculator-category'))) @@ -886,17 +897,16 @@ test('v1.4.1 Migration', async ({ page }) => { // Assert localStorage entries for the workout calculator are correct expect(await page.evaluate(() => - localStorage.getItem('running-tools.workout-calculator-input'))).toEqual(JSON.stringify({ - distanceValue: 1, - distanceUnit: 'miles', - time: 301, - })); + localStorage.getItem('running-tools.workout-calculator-input'))).toBeNull(); expect(await page.evaluate(() => localStorage.getItem('running-tools.workout-calculator-options'))).toEqual(JSON.stringify({ - model: 'VO2MaxModel', - riegelExponent: 1.06, - selectedTargetSet: '_workout_targets', customTargetNames: false, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 301, + }, + selectedTargetSet: '_workout_targets', })); expect(await page.evaluate(() => localStorage.getItem('running-tools.workout-calculator-target-set'))).toBeNull(); @@ -929,8 +939,26 @@ test('v1.4.1 Migration', async ({ page }) => { // Assert UI options are up to date // Very similar to the previous "go back and assert the options are not resset" section { - // Assert pace results are correct (inputs and options not reset) + // Assert batch options are correct await page.getByRole('button', { name: 'Batch Calculator' }).click(); + await expect(page.getByLabel('Input distance value')).toHaveValue('2.00'); + await expect(page.getByLabel('Input distance unit')).toHaveValue('miles'); + await expect(page.getByLabel('Input duration hours')).toHaveValue('0'); + await expect(page.getByLabel('Input duration minutes')).toHaveValue('10'); + await expect(page.getByLabel('Input duration seconds')).toHaveValue('30.00'); + await expect(page.getByLabel('Duration increment minutes')).toHaveValue('00'); + await expect(page.getByLabel('Duration increment seconds')).toHaveValue('10.00'); + await expect(page.getByLabel('Number of rows')).toHaveValue('15'); + await expect(page.getByLabel('Calculator')).toHaveValue('race'); + + // Assert advanced options are correct for race calculator mode + await page.getByText('Advanced Options').click(); + await expect(page.getByLabel('Default units')).toHaveValue('metric'); + await expect(page.getByLabel('Selected target set')).toHaveValue('_race_targets'); + await expect(page.getByLabel('Prediction model')).toHaveValue('RiegelModel'); + await expect(page.getByLabel('Riegel Exponent')).toHaveValue('1.06'); + + // Assert race results are correct (inputs and options not reset) 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'); await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(17); @@ -942,8 +970,12 @@ test('v1.4.1 Migration', async ({ page }) => { await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); await expect(page.getByRole('row')).toHaveCount(16); - // Assert pace results are correct (inputs and options not reset, new pace targets loaded) + // Assert advanced options are correct for pace calculator mode await page.getByLabel('Calculator').selectOption('Pace Calculator'); + await expect(page.getByLabel('Default units')).toHaveValue('metric'); + await expect(page.getByLabel('Selected target set')).toHaveValue('123456789'); + + // Assert pace results are correct (inputs and options not reset, new pace targets loaded) 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'); await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(4); @@ -955,14 +987,21 @@ test('v1.4.1 Migration', async ({ page }) => { await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(4); await expect(page.getByRole('row')).toHaveCount(16); - // Assert workout results are correct (new workout options loaded) + // Assert advanced options are correct for workout calculator mode await page.getByLabel('Calculator').selectOption('Workout Calculator'); + await page.getByText('Advanced Options').click(); + await expect(page.getByLabel('Default units')).toHaveValue('metric'); + await expect(page.getByLabel('Selected target set')).toHaveValue('_workout_targets'); await expect(page.getByLabel('Target name customization')).toHaveValue('false'); + await expect(page.getByLabel('Prediction model')).toHaveValue('RiegelModel'); + await expect(page.getByLabel('Riegel Exponent')).toHaveValue('1.06'); + + // Assert workout results are correct (new workout options loaded) 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); await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); - await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:42'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(2)).toHaveText('2:41'); await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); await expect(page.getByRole('row').nth(15).getByRole('cell').nth(2)).toHaveText('3:17'); @@ -976,6 +1015,16 @@ test('v1.4.1 Migration', async ({ page }) => { await page.getByRole('link', { name: 'Back' }).click(); await page.getByRole('button', { name: 'Pace Calculator' }).click(); + // Assert pace calculator options are correct + await expect(page.getByLabel('Input distance value')).toHaveValue('2.00'); + await expect(page.getByLabel('Input distance unit')).toHaveValue('miles'); + await expect(page.getByLabel('Input duration hours')).toHaveValue('0'); + await expect(page.getByLabel('Input duration minutes')).toHaveValue('15'); + await expect(page.getByLabel('Input duration seconds')).toHaveValue('30.00'); + await page.getByText('Advanced Options').click(); + await expect(page.getByLabel('Default units')).toHaveValue('metric'); + await expect(page.getByLabel('Selected target set')).toHaveValue('123456789'); + // Assert paces are correct (input pace not reset) await expect(page.getByRole('row').nth(1)).toHaveText('0.4 km' + '1:55.58'); await expect(page.getByRole('row').nth(2)).toHaveText('800 m' + '3:51.15'); @@ -986,6 +1035,18 @@ test('v1.4.1 Migration', async ({ page }) => { await page.getByRole('link', { name: 'Back' }).click(); await page.getByRole('button', { name: 'Race Calculator' }).click(); + // Assert race calculator options are correct + await expect(page.getByLabel('Input race distance value')).toHaveValue('2.00'); + await expect(page.getByLabel('Input race distance unit')).toHaveValue('miles'); + await expect(page.getByLabel('Input race duration hours')).toHaveValue('0'); + await expect(page.getByLabel('Input race duration minutes')).toHaveValue('10'); + await expect(page.getByLabel('Input race duration seconds')).toHaveValue('30.00'); + await page.getByText('Advanced Options').click(); + await expect(page.getByLabel('Default units')).toHaveValue('metric'); + await expect(page.getByLabel('Selected target set')).toHaveValue('_race_targets'); + await expect(page.getByLabel('Prediction model')).toHaveValue('RiegelModel'); + await expect(page.getByLabel('Riegel Exponent')).toHaveValue('1.06'); + // Assert race predictions are correct (input race not resset and new prediction model loaded) await expect(page.getByRole('row').nth(5)).toHaveText('1 mi' + '5:02.17' + '3:08 / km'); await expect(page.getByRole('row').nth(10)).toHaveText('5 km' + '16:44.87' + '3:21 / km'); @@ -995,6 +1056,10 @@ test('v1.4.1 Migration', async ({ page }) => { await page.getByRole('link', { name: 'Back' }).click(); await page.getByRole('button', { name: 'Split Calculator' }).click(); + // Assert split calculator options are correct + await expect(page.getByLabel('Default units')).toHaveValue('metric'); + await expect(page.getByLabel('Selected target set')).toHaveValue('_split_targets'); + // Assert times and paces are correct (split times not reset) await expect(page.getByRole('row').nth(1).getByRole('cell').nth(1)).toHaveText('7:00.00'); await expect(page.getByRole('row').nth(1).getByRole('cell').nth(3)).toHaveText('4:23 / km'); @@ -1015,9 +1080,22 @@ test('v1.4.1 Migration', async ({ page }) => { await page.getByRole('link', { name: 'Back' }).click(); await page.getByRole('button', { name: 'Workout Calculator' }).click(); + // Assert workout calculator options are correct + await expect(page.getByLabel('Input race distance value')).toHaveValue('1.00'); + await expect(page.getByLabel('Input race distance unit')).toHaveValue('miles'); + await expect(page.getByLabel('Input race duration hours')).toHaveValue('0'); + await expect(page.getByLabel('Input race duration minutes')).toHaveValue('05'); + await expect(page.getByLabel('Input race duration seconds')).toHaveValue('01.00'); + await page.getByText('Advanced Options').click(); + await expect(page.getByLabel('Default units')).toHaveValue('metric'); + await expect(page.getByLabel('Selected target set')).toHaveValue('_workout_targets'); + await expect(page.getByLabel('Target name customization')).toHaveValue('false'); + await expect(page.getByLabel('Prediction model')).toHaveValue('RiegelModel'); + await expect(page.getByLabel('Riegel Exponent')).toHaveValue('1.06'); + // Assert workout splits are correct (input race and prediction model not reset) await expect(page.getByRole('row').nth(1)).toHaveText('400 m @ 1 mi' + '1:14.81'); - await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:53.56'); + await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:44.38'); await expect(page.getByRole('row')).toHaveCount(5); } }); diff --git a/tests/unit/components/AdvancedOptionsInput.spec.js b/tests/unit/components/AdvancedOptionsInput.spec.js @@ -6,8 +6,19 @@ test('should be correctly render pace options according to props', () => { // Initialize component const wrapper = shallowMount(AdvancedOptionsInput, { propsData: { - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + }, options: { + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: 'B', }, targetSets: { @@ -66,10 +77,19 @@ test('should be correctly render race options according to props', () => { // Initialize component const wrapper = shallowMount(AdvancedOptionsInput, { propsData: { - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + }, options: { - model: 'PurdyPointsModel', - riegelExponent: 1.2, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_new', }, type: 'race', @@ -96,10 +116,19 @@ test('should render riegel exponent field only for supported race prediction mod // Initialize component const wrapper = shallowMount(AdvancedOptionsInput, { propsData: { - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.2, + }, + }, options: { - model: 'AverageModel', - riegelExponent: 1.2, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_new', }, type: 'race', @@ -132,7 +161,13 @@ test('should be correctly render split options according to props', () => { // Initialize component const wrapper = shallowMount(AdvancedOptionsInput, { propsData: { - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + }, options: { selectedTargetSet: '_new', }, @@ -158,11 +193,20 @@ test('should be correctly render workout options according to props', () => { // Initialize component const wrapper = shallowMount(AdvancedOptionsInput, { propsData: { - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + }, options: { customTargetNames: true, - model: 'PurdyPointsModel', - riegelExponent: 1.2, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -187,11 +231,20 @@ test('should only show batch column label field when applicable', async () => { // Initialize component with workout target name customization enabled const wrapper = shallowMount(AdvancedOptionsInput, { propsData: { - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + }, options: { customTargetNames: true, - model: 'PurdyPointsModel', - riegelExponent: 1.2, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -204,24 +257,33 @@ test('should only show batch column label field when applicable', async () => { expect(wrapper.findAll('input[aria-label="Batch column label"]')).to.have .length(0); - // Add batchInput and batchOptions but disable workout target name customization + // Add batchOptions but disable workout target name customization await wrapper.setProps({ - batchInput: { // added - distanceValue: 2, - distanceUnit: 'miles', - time: 600, - }, batchOptions: { // added calculator: 'workout', increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, label: 'foo', rows: 15, }, - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + }, options: { customTargetNames: false, // disabled - model: 'PurdyPointsModel', - riegelExponent: 1.2, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -233,22 +295,31 @@ test('should only show batch column label field when applicable', async () => { // Enable workout target name customization await wrapper.setProps({ - batchInput: { - distanceValue: 2, - distanceUnit: 'miles', - time: 600, - }, batchOptions: { calculator: 'workout', increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, label: 'foo', rows: 15, }, - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + }, options: { customTargetNames: true, // enabled - model: 'PurdyPointsModel', - riegelExponent: 1.2, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -262,21 +333,30 @@ test('should only show batch column label field when applicable', async () => { // Switch to race calculator await wrapper.setProps({ - batchInput: { - distanceValue: 2, - distanceUnit: 'miles', - time: 600, - }, batchOptions: { calculator: 'workout', increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, label: 'foo', rows: 15, }, - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + }, options: { - model: 'PurdyPointsModel', - riegelExponent: 1.2, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -292,11 +372,20 @@ test('should pass correct props to TargetSetSelector', async () => { // Initialize component const wrapper = shallowMount(AdvancedOptionsInput, { propsData: { - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + }, options: { customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: 'B', }, targetSets: { @@ -356,11 +445,20 @@ test('should emit input events when options are modified', async () => { // Initialize component const wrapper = shallowMount(AdvancedOptionsInput, { propsData: { - defaultUnitSystem: 'metric', + globalOptions: { + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, options: { customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_new', }, targetSets: {}, @@ -394,7 +492,29 @@ test('should emit input events when options are modified', async () => { await wrapper.findComponent({ name: 'decimal-input' }).setValue(1.3); // Assert correct update events emitted - expect(wrapper.emitted()['update:defaultUnitSystem']).to.deep.equal([['imperial']]); + expect(wrapper.emitted()['update:globalOptions']).to.deep.equal([ + [{ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }], + [{ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.06, + }, + }], + [{ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.3, + }, + }], + ]); expect(wrapper.emitted()['update:targetSets']).to.deep.equal([[{ 'A': { name: '1st target set v2', @@ -415,28 +535,22 @@ 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, - selectedTargetSet: 'B', - }], - [{ - customTargetNames: true, - model: 'AverageModel', - riegelExponent: 1.06, - selectedTargetSet: 'B', - }], - [{ - customTargetNames: true, - model: 'CameronModel', - riegelExponent: 1.06, - selectedTargetSet: 'B', + customTargetNames: false, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: 'B', }], [{ - customTargetNames: true, - model: 'CameronModel', - riegelExponent: 1.3, - selectedTargetSet: 'B', + customTargetNames: true, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + 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/migration.spec.js b/tests/unit/core/migration.spec.js @@ -0,0 +1,150 @@ +import { beforeEach, describe, test, expect } from 'vitest'; +import { migrateLocalStorage } from '@/core/migrations'; +import { detectDefaultUnitSystem } from '@/core/units'; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('migrateLocalStorage method', () => { + test('should correctly migrate <=1.4.1 calculator options', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.default-unit-system', '"imperial"'); + + localStorage.setItem('running-tools.batch-calculator-input', + '{"distanceValue":100,"distanceUnit":"meters","time":10}'); + localStorage.setItem('running-tools.batch-calculator-options', + '{"calculator":"race","increment":32,"rows":15}'); + + localStorage.setItem('running-tools.pace-calculator-input', + '{"distanceValue":110,"distanceUnit":"meters","time":11}'); + localStorage.setItem('running-tools.pace-calculator-target-set', '"A"'); + + localStorage.setItem('running-tools.race-calculator-input', + '{"distanceValue":120,"distanceUnit":"meters","time":12}'); + localStorage.setItem('running-tools.race-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.07}'); + localStorage.setItem('running-tools.race-calculator-target-set', '"B"'); + + localStorage.setItem('running-tools.split-calculator-target-set', '"C"'); + + localStorage.setItem('running-tools.workout-calculator-input', + '{"distanceValue":130,"distanceUnit":"meters","time":13}'); + localStorage.setItem('running-tools.workout-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.08}'); + localStorage.setItem('running-tools.workout-calculator-target-set', '"D"'); + + // Run migrations + migrateLocalStorage(); + + // Assert localStorage entries correctly migrated + expect(localStorage.getItem('running-tools.default-unit-system')).to.equal(null); + expect(localStorage.getItem('running-tools.global-options')).to.equal( + '{"defaultUnitSystem":"imperial","racePredictionOptions":{"model":"RiegelModel",' + + '"riegelExponent":1.07}}'); + + expect(localStorage.getItem('running-tools.batch-calculator-input')).to.equal(null); + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal( + '{"calculator":"race","increment":32,"rows":15,"label":"",' + + '"input":{"distanceValue":100,"distanceUnit":"meters","time":10}}'); + + expect(localStorage.getItem('running-tools.pace-calculator-input')).to.equal(null); + expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal( + '{"input":{"distanceValue":110,"distanceUnit":"meters","time":11},"selectedTargetSet":"A"}'); + expect(localStorage.getItem('running-tools.pace-calculator-target-set')).to.equal(null); + + expect(localStorage.getItem('running-tools.race-calculator-input')).to.equal(null); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( + '{"input":{"distanceValue":120,"distanceUnit":"meters","time":12},"selectedTargetSet":"B"}'); + expect(localStorage.getItem('running-tools.race-calculator-target-set')).to.equal(null); + + expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal( + '{"selectedTargetSet":"C"}'); + expect(localStorage.getItem('running-tools.split-calculator-target-set')).to.equal(null); + + expect(localStorage.getItem('running-tools.workout-calculator-input')).to.equal(null); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( + '{"customTargetNames":false,"input":{"distanceValue":130,"distanceUnit":"meters",' + + '"time":13},"selectedTargetSet":"D"}'); + expect(localStorage.getItem('running-tools.workout-calculator-target-set')).to.equal(null); + }); + + test('should correctly migrate partial <=1.4.1 calculator options using default values', async () => { + // Initialize localStorage + // default-unit-system, *-calculator-input, and *-calculator-target-set left undefined + localStorage.setItem('running-tools.batch-calculator-options', + '{"calculator":"race","increment":32,"rows":15}'); + localStorage.setItem('running-tools.race-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.07}'); + localStorage.setItem('running-tools.workout-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.08}'); + + // Run migrations + migrateLocalStorage(); + + // Assert localStorage entries correctly migrated + const defaultUnitSystem = detectDefaultUnitSystem(); + expect(localStorage.getItem('running-tools.global-options')).to.equal( + `{"defaultUnitSystem":"${defaultUnitSystem}",` + + '"racePredictionOptions":{"model":"RiegelModel","riegelExponent":1.07}}'); + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal( + '{"calculator":"race","increment":32,"rows":15,"label":"",' + + '"input":{"distanceValue":5,"distanceUnit":"kilometers","time":1200}}'); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( + '{"input":{"distanceValue":5,"distanceUnit":"kilometers","time":1200},' + + '"selectedTargetSet":"_race_targets"}'); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( + '{"customTargetNames":false,"input":{"distanceValue":5,"distanceUnit":"kilometers",' + + '"time":1200},"selectedTargetSet":"_workout_targets"}'); + }); + + test('should not modify >1.4.1 calculator options', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.global-calculator-options', + '{"model":"RiegelModel","riegelExponent":1.07}'); + localStorage.setItem('running-tools.batch-calculator-options', + '{"calculator":"race","increment":32,"input":{"distanceValue":100,"distanceUnit":"meters",' + + '"time":10},"label":"foo","rows":15}'); + localStorage.setItem('running-tools.pace-calculator-options', + '{"input":{"distanceValue":110,"distanceUnit":"meters","time":11},"selectedTargetSet":"A"}'); + localStorage.setItem('running-tools.race-calculator-options', + '{"input":{"distanceValue":120,"distanceUnit":"meters","time":12},"selectedTargetSet":"B"}'); + localStorage.setItem('running-tools.split-calculator-options', + '{"selectedTargetSet":"C"}'); + localStorage.setItem('running-tools.workout-calculator-options', + '{"customTargetNames":true,"input":{"distanceValue":120,"distanceUnit":"meters","time":12},' + + '"selectedTargetSet":"D"}'); + + // Run migrations + migrateLocalStorage(); + + // Assert localStorage entries not modified + expect(localStorage.getItem('running-tools.global-calculator-options')).to.equal( + '{"model":"RiegelModel","riegelExponent":1.07}'); + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal( + '{"calculator":"race","increment":32,"input":{"distanceValue":100,"distanceUnit":"meters",' + + '"time":10},"label":"foo","rows":15}'); + expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal( + '{"input":{"distanceValue":110,"distanceUnit":"meters","time":11},"selectedTargetSet":"A"}'); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( + '{"input":{"distanceValue":120,"distanceUnit":"meters","time":12},"selectedTargetSet":"B"}'); + expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal( + '{"selectedTargetSet":"C"}'); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( + '{"customTargetNames":true,"input":{"distanceValue":120,"distanceUnit":"meters","time":12},' + + '"selectedTargetSet":"D"}'); + }); + + test('should not modify missing calculator options', async () => { + // Run migrations + migrateLocalStorage(); + + // Assert localStorage entries not modified + expect(localStorage.getItem('running-tools.global-options')).to.equal(null); + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(null); + expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal(null); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(null); + expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal(null); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(null); + }); +}); 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/core/utils.spec.js b/tests/unit/core/utils.spec.js @@ -130,7 +130,7 @@ describe('deepEqual method', () => { }); }); -describe('get method', () => { +describe('getLocalStorage method', () => { test('should correctly parse correct localStorage item', async () => { // Initialize localStorage localStorage.setItem('running-tools.foo', '{"bar":123}'); @@ -156,7 +156,7 @@ describe('get method', () => { }); }); -describe('set method', () => { +describe('setLocalStorage method', () => { test('should correctly set new localStorage item', async () => { // Set localStorage item utils.setLocalStorage('foo', { baz: 456 }); @@ -177,95 +177,30 @@ describe('set method', () => { }); }); -describe('migrate method', () => { - test('should correctly migrate <=1.4.1 calculator options', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.batch-calculator-options', - '{"calculator":"race","increment":32,"rows":15}'); - localStorage.setItem('running-tools.pace-calculator-target-set', '"A"'); - localStorage.setItem('running-tools.race-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.07}'); - localStorage.setItem('running-tools.race-calculator-target-set', '"B"'); - localStorage.setItem('running-tools.split-calculator-target-set', '"C"'); - localStorage.setItem('running-tools.workout-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.08}'); - localStorage.setItem('running-tools.workout-calculator-target-set', '"D"'); - - // Run migrations - utils.migrateLocalStorage(); - - // Assert localStorage entries correctly migrated - expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal( - '{"calculator":"race","increment":32,"rows":15,"label":""}'); - expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal( - '{"selectedTargetSet":"A"}'); - expect(localStorage.getItem('running-tools.pace-calculator-target-set')).to.equal(null); - expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); - expect(localStorage.getItem('running-tools.race-calculator-target-set')).to.equal(null); - expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal( - '{"selectedTargetSet":"C"}'); - expect(localStorage.getItem('running-tools.split-calculator-target-set')).to.equal(null); - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.08,"selectedTargetSet":"D",' + - '"customTargetNames":false}'); - expect(localStorage.getItem('running-tools.workout-calculator-target-set')).to.equal(null); - }); - - test('should correctly migrate partial <=1.4.1 calculator options', async () => { - // Initialize localStorage (workout-target-set option missing) - localStorage.setItem('running-tools.workout-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.08}'); +describe('unsetLocalStorage method', () => { + test('should correctly remove existing localStorage item', async () => { + // Set localStorage item + localStorage.setItem('running-tools.foo', '1'); + localStorage.setItem('running-tools.bar', '2'); - // Run migrations - utils.migrateLocalStorage(); + // Remove localStorage item + utils.unsetLocalStorage('bar'); - // Assert localStorage entries correctly migrated - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.08,"selectedTargetSet":"_workout_targets",' + - '"customTargetNames":false}'); + // Assert localStorage updated correctly + expect(localStorage.getItem('running-tools.foo')).to.equal('1'); + expect(localStorage.getItem('running-tools.bar')).to.equal(null); + expect(localStorage.length).to.equal(1); }); - test('should not modify >1.4.1 calculator options', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.batch-calculator-options', - '{"calculator":"race","increment":32,"label":"foo","rows":15}'); - localStorage.setItem('running-tools.pace-calculator-options', - '{"selectedTargetSet":"A"}'); - localStorage.setItem('running-tools.race-calculator-options', - '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); - localStorage.setItem('running-tools.split-calculator-options', - '{"selectedTargetSet":"C"}'); - localStorage.setItem('running-tools.workout-calculator-options', - '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.08,' + - '"selectedTargetSet":"D"}'); - - // Run migrations - utils.migrateLocalStorage(); - - // Assert localStorage entries not modified - expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal( - '{"calculator":"race","increment":32,"label":"foo","rows":15}'); - expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal( - '{"selectedTargetSet":"A"}'); - expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal( - '{"model":"RiegelModel","riegelExponent":1.07,"selectedTargetSet":"B"}'); - expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal( - '{"selectedTargetSet":"C"}'); - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal( - '{"customTargetNames":true,"model":"PurdyPointsModel","riegelExponent":1.08,' + - '"selectedTargetSet":"D"}'); - }); + test('should remove non-existant localStorage item without error', async () => { + // Set localStorage item + localStorage.setItem('running-tools.foo', '1'); - test('should not modify missing calculator options', async () => { - // Run migrations - utils.migrateLocalStorage(); + // Remove localStorage item + utils.unsetLocalStorage('missing'); - // Assert localStorage entries not modified - expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(null); - expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal(null); - expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(null); - expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal(null); - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(null); + // Assert localStorage updated correctly + expect(localStorage.length).to.equal(1); + expect(localStorage.getItem('running-tools.foo')).to.equal('1'); }); }); diff --git a/tests/unit/views/BatchCalculator.spec.js b/tests/unit/views/BatchCalculator.spec.js @@ -1,59 +1,108 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import BatchCalculator from '@/views/BatchCalculator.vue'; +import { defaultTargetSets } from '@/core/targets'; +import { detectDefaultUnitSystem } from '@/core/units'; beforeEach(() => { localStorage.clear(); -}) - -test('should load input from localStorage', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.batch-calculator-input', JSON.stringify({ - distanceValue: 2, - distanceUnit: 'miles', - time: 600, - })); +}); +test('should initialize regular options to default values', async () => { // Initialize component const wrapper = shallowMount(BatchCalculator); - // Assert options loaded + // Assert regular options are initialized expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ - distanceValue: 2, - distanceUnit: 'miles', - time: 600, + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, }); - expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.batchInput).to.deep.equal({ - distanceValue: 2, - distanceUnit: 'miles', - time: 600, + expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(15); + expect(wrapper.findComponent({ name: 'integer-input' }).vm.modelValue).to.equal(20); + expect(wrapper.find('select[aria-label="Calculator"]').element.value).to.equal('workout'); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions).to.deep.equal({ + defaultUnitSystem: detectDefaultUnitSystem(), + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.batchOptions).to.deep.equal({ + calculator: 'workout', + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + increment: 15, + label: '', + rows: 20, }); }); -test('should save input to localStorage when modified', async () => { +test('should initialize calculator options to default values', async () => { // Initialize component const wrapper = shallowMount(BatchCalculator); - // Update input pace - await wrapper.findComponent({ name: 'pace-input' }).setValue({ - distanceValue: 2, - distanceUnit: 'miles', - time: 600, + // Assert pace calculator options are initialized + await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: '_pace_targets', }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(defaultTargetSets._pace_targets.targets); - // Assert input saved - expect(localStorage.getItem('running-tools.batch-calculator-input')).to.equal(JSON.stringify({ - distanceValue: 2, - distanceUnit: 'miles', - time: 600, - })); + // 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({ + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: '_race_targets', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(defaultTargetSets._race_targets.targets); + + // Assert workout calculator options are loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + customTargetNames: false, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: '_workout_targets', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(defaultTargetSets._workout_targets.targets); }); -test('should load batch options from localStorage', async () => { +test('should load regular options from localStorage', async () => { // Initialize localStorage + localStorage.setItem('running-tools.global-options', JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + })); localStorage.setItem('running-tools.batch-calculator-options', JSON.stringify({ calculator: 'race', increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, label: 'foo', rows: 15, })); @@ -61,99 +110,37 @@ test('should load batch options from localStorage', async () => { // Initialize component const wrapper = shallowMount(BatchCalculator); + // Assert data loaded + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions).to.deep.equal({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + }); + // Assert options loaded expect(wrapper.find('select[aria-label="Calculator"]').element.value).to.equal('race'); + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }); expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(32); expect(wrapper.findComponent({ name: 'integer-input' }).vm.modelValue).to.equal(15); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.batchOptions).to.deep.equal({ calculator: 'race', + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, increment: 32, label: 'foo', rows: 15, }); }); -test('should save batch options to localStorage when modified', async () => { - // Initialize component - const wrapper = shallowMount(BatchCalculator); - - // Update increment value - await wrapper.findComponent({ name: 'time-input' }).setValue(32); - - // Assert options saved - expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ - calculator: 'workout', - increment: 32, - label: '', - rows: 20, - })); - - // Update number of rows - await wrapper.findComponent({ name: 'integer-input' }).setValue(15); - - // Assert options saved - expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ - calculator: 'workout', - increment: 32, - label: '', - rows: 15, - })); - - // Update batch column label - await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - calculator: 'workout', - increment: 32, - label: 'foo', - rows: 15, - }, 'batch-options'); - - // Assert options saved - expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ - calculator: 'workout', - increment: 32, - label: 'foo', - rows: 15, - })); - - // Update active calculator - await wrapper.find('select[aria-label="Calculator"]').setValue('race'); - - // Assert options saved - expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ - calculator: 'race', - increment: 32, - label: 'foo', - rows: 15, - })); -}); - -test('should load default units setting from localStorage', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.default-unit-system', '"metric"'); - - // Initialize component - const wrapper = shallowMount(BatchCalculator); - - // Assert default units setting loaded - expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.defaultUnitSystem) - .to.equal('metric'); -}); - -test('should save default units setting from localStorage when modified', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.default-unit-system', '"metric"'); - - // Initialize component - const wrapper = shallowMount(BatchCalculator); - - // Change default units setting - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('imperial', 'defaultUnitSystem'); - - // New default units should be saved to localStorage - expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"'); -}); - test('should load calculator options from localStorage', async () => { // Initialize localStorage const selectedTargetSets = [ @@ -207,17 +194,28 @@ test('should load calculator options from localStorage', async () => { } })); localStorage.setItem('running-tools.pace-calculator-options', JSON.stringify({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 300, + }, selectedTargetSet: 'A', })); localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, + input: { + distanceValue: 1.1, + distanceUnit: 'miles', + time: 310, + }, selectedTargetSet: 'C', })); localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ customTargetNames: true, - model: 'RiegelModel', - riegelExponent: 1.1, + input: { + distanceValue: 1.2, + distanceUnit: 'miles', + time: 320, + }, selectedTargetSet: 'E', })); @@ -227,6 +225,11 @@ test('should load calculator options from localStorage', async () => { // Assert pace calculator options are loaded await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + input: { + distanceValue: 1.0, + distanceUnit: 'miles', + time: 300, + }, selectedTargetSet: 'A', }); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -235,8 +238,11 @@ 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, + input: { + distanceValue: 1.1, + distanceUnit: 'miles', + time: 310, + }, selectedTargetSet: 'C', }); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -246,14 +252,137 @@ 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, + input: { + distanceValue: 1.2, + distanceUnit: 'miles', + time: 320, + }, selectedTargetSet: 'E', }); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) .to.deep.equal(selectedTargetSets[2].targets); }); +test('should save regular options to localStorage when modified', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.default-unit-system', '"metric"'); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Change default units setting + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + }, 'globalOptions'); + + // New default units should be saved to localStorage + expect(localStorage.getItem('running-tools.global-options')).to.equal(JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.30, + }, + })); + + // Update input pace + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }); + + // Assert options saved + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ + calculator: 'workout', + increment: 15, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, + label: '', + rows: 20, + })); + + // Update increment value + await wrapper.findComponent({ name: 'time-input' }).setValue(32); + + // Assert options saved + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ + calculator: 'workout', + increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, + label: '', + rows: 20, + })); + + // Update number of rows + await wrapper.findComponent({ name: 'integer-input' }).setValue(15); + + // Assert options saved + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ + calculator: 'workout', + increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, + label: '', + rows: 15, + })); + + // Update batch column label + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + calculator: 'workout', + increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, + label: 'foo', + rows: 15, + }, 'batch-options'); + + // Assert options saved + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ + calculator: 'workout', + increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, + label: 'foo', + rows: 15, + })); + + // Update active calculator + await wrapper.find('select[aria-label="Calculator"]').setValue('race'); + + // Assert options saved + expect(localStorage.getItem('running-tools.batch-calculator-options')).to.equal(JSON.stringify({ + calculator: 'race', + increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, + label: 'foo', + rows: 15, + })); +}); + test('should save calculator options to localStorage when modified', async () => { // Initialize localStorage const selectedTargetSets = [ @@ -289,6 +418,11 @@ test('should save calculator options to localStorage when modified', async () => } })); localStorage.setItem('running-tools.pace-calculator-options', JSON.stringify({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 300, + }, selectedTargetSet: 'B', })); localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({ @@ -301,8 +435,11 @@ test('should save calculator options to localStorage when modified', async () => } })); localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ - model: 'AverageModel', - riegelExponent: 1.06, + input: { + distanceValue: 1.1, + distanceUnit: 'miles', + time: 310, + }, selectedTargetSet: 'D', })); localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({ @@ -316,8 +453,11 @@ 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, + input: { + distanceValue: 1.2, + distanceUnit: 'miles', + time: 320, + }, selectedTargetSet: 'F', })); @@ -327,6 +467,11 @@ test('should save calculator options to localStorage when modified', async () => // Update pace calculator options X await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + input: { + distanceValue: 1.0, + distanceUnit: 'miles', + time: 300, + }, selectedTargetSet: 'A', }, 'options'); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -335,8 +480,11 @@ 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, + input: { + distanceValue: 1.1, + distanceUnit: 'miles', + time: 310, + }, selectedTargetSet: 'C', }, 'options'); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -346,8 +494,11 @@ 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, + input: { + distanceValue: 1.2, + distanceUnit: 'miles', + time: 320, + }, selectedTargetSet: 'E', }, 'options'); expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) @@ -355,17 +506,28 @@ test('should save calculator options to localStorage when modified', async () => // Assert options saved to localStorage expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal(JSON.stringify({ + input: { + distanceValue: 1.0, + distanceUnit: 'miles', + time: 300, + }, selectedTargetSet: 'A', })); expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(JSON.stringify({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, + input: { + distanceValue: 1.1, + distanceUnit: 'miles', + time: 310, + }, selectedTargetSet: 'C', })); expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({ customTargetNames: true, - model: 'RiegelModel', - riegelExponent: 1.1, + input: { + distanceValue: 1.2, + distanceUnit: 'miles', + time: 320, + }, selectedTargetSet: 'E', })); }); @@ -434,6 +596,11 @@ test('should pass correct input props to DoubleOutputTable', async () => { await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ calculator: 'workout', increment: 10, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, label: 'foo', rows: 15, }, 'batchOptions'); @@ -451,8 +618,11 @@ 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, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_workout_targets', }, 'options'); @@ -494,14 +664,14 @@ test('should correctly set AdvancedOptionsInput props', async () => { await wrapper.findComponent({ name: 'integer-input' }).setValue(15); // Assert batch props are correct - expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.batchInput).to.deep.equal({ - distanceValue: 2, - distanceUnit: 'miles', - time: 600, - }); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.batchOptions).to.deep.equal({ calculator: 'workout', increment: 32, + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }, label: '', rows: 15, }); @@ -509,43 +679,88 @@ test('should correctly set AdvancedOptionsInput props', async () => { // Assert props are correct for pace calculator await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.type).to.equal('pace'); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions).to.deep.equal({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_pace_targets', }); // Assert props are correct for race calculator 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.globalOptions).to.deep.equal({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ - model: 'AverageModel', - riegelExponent: 1.06, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_race_targets', }); // Assert props are correct for workout calculator await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.type).to.equal('workout'); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions).to.deep.equal({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_workout_targets', }); }); test('should correctly calculate outputs', async () => { // Initialize localStorage + localStorage.setItem('running-tools.global-options', JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + })); localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ + input: { + distanceValue: 1.1, + distanceUnit: 'miles', + time: 310, + }, selectedTargetSet: '_race_targets', - model: 'PurdyPointsModel', - riegelExponent: 1.2, })); localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ + customTargetNames: false, + input: { + distanceValue: 1.2, + distanceUnit: 'miles', + time: 320, + }, selectedTargetSet: '_workout_targets', - model: 'RiegelModel', - riegelExponent: 1.1, })); - localStorage.setItem('running-tools.default-unit-system', '"imperial"'); // Initialize component const wrapper = shallowMount(BatchCalculator); @@ -579,8 +794,8 @@ test('should correctly calculate outputs', async () => { const workoutTarget = { type: 'time', time: 3600, splitValue: 1, splitUnit: 'miles' }; const result = calculate(input, workoutTarget); expect(result.key).to.equal('1 mi @ 1:00:00'); - expect(result.value).to.equal('5:53'); + expect(result.value).to.equal('5:29'); expect(result.pace).to.equal(''); - expect(result.sort).to.be.closeTo(353.07, 0.01); + expect(result.sort).to.be.closeTo(329.48, 0.01); expect(result.result).to.equal('value'); }); diff --git a/tests/unit/views/PaceCalculator.spec.js b/tests/unit/views/PaceCalculator.spec.js @@ -2,10 +2,183 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import PaceCalculator from '@/views/PaceCalculator.vue'; import { defaultTargetSets } from '@/core/targets'; +import { detectDefaultUnitSystem } from '@/core/units'; beforeEach(() => { localStorage.clear(); -}) +}); + +test('should initialize options to default values', async () => { + // Initialize component + const wrapper = shallowMount(PaceCalculator); + + // Assert options are initialized + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions + .defaultUnitSystem).to.equal(detectDefaultUnitSystem()); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: '_pace_targets', + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.targetSets) + .to.deep.equal({ _pace_targets: defaultTargetSets._pace_targets }); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(defaultTargetSets._pace_targets.targets); +}); + +test('should load options from localStorage', async () => { + const targetSets = { + '_pace_targets': { + name: 'Pace targets #1', + targets: [ + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + ], + }, + 'B': { + name: 'Pace targets #2', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + }; + + // Initialize localStorage + localStorage.setItem('running-tools.global-options', JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + })); + localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify(targetSets)); + localStorage.setItem('running-tools.pace-calculator-options', JSON.stringify({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + })); + + // Initialize component + const wrapper = shallowMount(PaceCalculator); + + // Assert options are loaded + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions + .defaultUnitSystem).to.equal('imperial'); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.targetSets) + .to.deep.equal(targetSets); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(targetSets.B.targets); +}); + +test('should save options to localStorage when modified', async () => { + const targetSets = { + '_pace_targets': { + name: 'Pace targets #1', + targets: [ + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + ], + }, + 'B': { + name: 'Pace targets #2', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + }; + + // Initialize component + const wrapper = shallowMount(PaceCalculator); + + // Change default units + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, 'globalOptions'); + + // New default units should be saved to localStorage + expect(localStorage.getItem('running-tools.global-options')).to.equal(JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + })); + + // Update input pace + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); + + // New input pace should be saved to localStorage + expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal(JSON.stringify({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: '_pace_targets', + })); + + // Update target sets and selected target set + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue(targetSets, + 'targetSets'); + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + }, 'options'); + + // New selected target set should be saved to localStorage + expect(localStorage.getItem('running-tools.pace-calculator-target-sets')) + .to.equal(JSON.stringify(targetSets)); + expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal(JSON.stringify({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + })); +}); test('should correctly calculate time results', async () => { // Initialize component @@ -36,7 +209,7 @@ test('should correctly calculate time results', async () => { }); }); -test('should correctly calculate distance results according to default units setting', async () => { +test('should correctly calculate distance results according to global options', async () => { // Initialize component const wrapper = shallowMount(PaceCalculator); @@ -48,8 +221,13 @@ test('should correctly calculate distance results according to default units set }); // Set default units - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('metric', 'defaultUnitSystem'); + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, 'globalOptions'); // Get calculate result function const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; @@ -59,8 +237,13 @@ test('should correctly calculate distance results according to default units set expect(result.key).to.equal('1.61 km'); // Change default units - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('imperial', 'defaultUnitSystem'); + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, 'globalOptions'); // Assert result is correct result = calculateResult({ type: 'time', time: 600 }); @@ -80,15 +263,27 @@ test('should correctly handle null target set', async () => { const wrapper = shallowMount(PaceCalculator); // Switch to invalid target set - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue({ selectedTargetSet: 'does_not_exist' }, 'options'); + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: 'does_not_exist' + }, 'options'); // Assert empty array passed to SingleOutputTable component expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets).to.deep.equal([]); // Switch to valid target set - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue({ selectedTargetSet: '_pace_targets' }, 'options'); + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: '_pace_targets' + }, 'options'); // Assert valid targets passed to SingleOutputTable component const paceTargets = defaultTargetSets._pace_targets.targets; @@ -96,103 +291,10 @@ test('should correctly handle null target set', async () => { .to.deep.equal(paceTargets); }); -test('should load input pace from localStorage', async () => { - // Initialize localStorage - 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: 'pace-input' }).vm.modelValue).to.deep.equal({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - }); -}); - -test('should save input pace to localStorage', async () => { - // Initialize component - const wrapper = shallowMount(PaceCalculator); - - // Enter input pace data - 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')).to.equal(JSON.stringify({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - })); -}); - -test('should load options from localStorage', async () => { - // Initialize localStorage - const targetSet2 = { - name: 'Pace targets #2', - targets: [ - { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, - { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, - { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, - ], - }; - localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify({ - '_pace_targets': { - name: 'Pace targets #1', - targets: [ - { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, - { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, - { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, - ], - }, - 'B': targetSet2, - })); - localStorage.setItem('running-tools.pace-calculator-options', JSON.stringify({ - selectedTargetSet: 'B', - })); - +test('should correctly set AdvancedOptionsInput type prop', async () => { // Initialize component const wrapper = shallowMount(PaceCalculator); - // Assert selection is loaded - expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options) - .to.deep.equal({ selectedTargetSet: 'B' }); - expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) - .to.deep.equal(targetSet2.targets); -}); - -test('should save options to localStorage when modified', async () => { - // Initialize component - const wrapper = shallowMount(PaceCalculator); - - // Select a new target set - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue({ selectedTargetSet: 'B' }, 'options'); - - // New selected target set should be saved to localStorage - expect(localStorage.getItem('running-tools.pace-calculator-options')).to.equal(JSON.stringify({ - selectedTargetSet: 'B', - })); -}); - -test('should save default units setting to localStorage when modified', async () => { - // Initialize component - const wrapper = shallowMount(PaceCalculator); - - // Change default units - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('metric', 'defaultUnitSystem'); - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('imperial', 'defaultUnitSystem'); - - // New default units should be saved to localStorage - expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"'); + // Assert type prop is correctly set + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.type).to.equal('pace'); }); diff --git a/tests/unit/views/RaceCalculator.spec.js b/tests/unit/views/RaceCalculator.spec.js @@ -2,39 +2,195 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import RaceCalculator from '@/views/RaceCalculator.vue'; import { defaultTargetSets } from '@/core/targets'; +import { detectDefaultUnitSystem } from '@/core/units'; beforeEach(() => { localStorage.clear(); -}) +}); -test('should correctly predict race times', async () => { +test('should initialize options to default values', async () => { // Initialize component const wrapper = shallowMount(RaceCalculator); - // Enter input race data - await wrapper.findComponent({ name: 'pace-input' }).setValue({ + // Assert options are initialized + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ distanceValue: 5, distanceUnit: 'kilometers', time: 1200, }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions).to.deep.equal({ + defaultUnitSystem: detectDefaultUnitSystem(), + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: '_race_targets', + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.targetSets) + .to.deep.equal({ _race_targets: defaultTargetSets._race_targets }); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(defaultTargetSets._race_targets.targets); +}); - // Calculate result - const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; - const result = calculateResult({ - distanceValue: 10, - distanceUnit: 'kilometers', - type: 'distance', +test('should load options from localStorage', async () => { + const targetSets = { + '_race_targets': { + name: 'Race targets #1', + targets: [ + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + ], + }, + 'B': { + name: 'Race targets #2', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + }; + + // Initialize localStorage + localStorage.setItem('running-tools.global-options', JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + })); + localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify(targetSets)); + localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + })); + + // Initialize component + const wrapper = shallowMount(RaceCalculator); + + // Assert options are loaded + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions).to.deep.equal({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, }); + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.targetSets) + .to.deep.equal(targetSets); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(targetSets.B.targets); +}); - // Assert result is correct - expect(result.key).to.equal('10 km'); - expect(result.value).to.equal('41:34.80'); - expect(result.pace).to.equal('6:41 / mi'); - expect(result.result).to.equal('value'); - expect(result.sort).to.be.closeTo(2494.80, 0.01); +test('should save options to localStorage when modified', async () => { + const targetSets = { + '_race_targets': { + name: 'Race targets #1', + targets: [ + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + ], + }, + 'B': { + name: 'Race targets #2', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + }; + + // Initialize component + const wrapper = shallowMount(RaceCalculator); + + // Update options + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + }, 'globalOptions'); + + // New global options should be saved to localStorage + expect(localStorage.getItem('running-tools.global-options')).to.equal(JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + })); + + // Update input race + 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-options')).to.equal(JSON.stringify({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: '_race_targets', + })); + + // Update target sets and selected target set + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue(targetSets, + 'targetSets'); + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + }, 'options'); + + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.race-calculator-target-sets')) + .to.equal(JSON.stringify(targetSets)); + expect(localStorage.getItem('running-tools.race-calculator-options')).to.equal(JSON.stringify({ + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + })); }); -test('should correctly calculate distance results according to default units setting', async () => { +test('should correctly predict race times', async () => { // Initialize component const wrapper = shallowMount(RaceCalculator); @@ -45,32 +201,20 @@ test('should correctly calculate distance results according to default units set time: 1200, }); - // Set default units - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('metric', 'defaultUnitSystem'); - - // Get calculate result function + // Calculate result const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; + const result = calculateResult({ + distanceValue: 10, + distanceUnit: 'kilometers', + type: 'distance', + }); // Assert result is correct - let result = calculateResult({ type: 'time', time: 2495 }); - expect(result.key).to.equal('10.00 km'); - expect(result.value).to.equal('41:35'); - expect(result.pace).to.equal('4:09 / km'); - expect(result.result).to.equal('key'); - expect(result.sort).to.equal(2495); - - // Change default units - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('imperial', 'defaultUnitSystem'); - - // Assert result is correct - result = calculateResult({ type: 'time', time: 2495 }); - expect(result.key).to.equal('6.21 mi'); - expect(result.value).to.equal('41:35'); + expect(result.key).to.equal('10 km'); + expect(result.value).to.equal('41:34.80'); expect(result.pace).to.equal('6:41 / mi'); - expect(result.result).to.equal('key'); - expect(result.sort).to.equal(2495); + expect(result.result).to.equal('value'); + expect(result.sort).to.be.closeTo(2494.80, 0.01); }); test('should show paces in results table', async () => { @@ -87,8 +231,11 @@ 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, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: 'does_not_exist', }, 'options'); @@ -97,8 +244,11 @@ 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, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_race_targets', }, 'options'); @@ -131,7 +281,7 @@ test('should correctly calculate race statistics', async () => { expect(vo2Max).to.equal('V̇O₂ Max: 49.8 ml/kg/min') }); -test('should correctly calculate results according to model options', async () => { +test('should correctly calculate results according to options', async () => { // Initialize component const wrapper = shallowMount(RaceCalculator); @@ -142,16 +292,54 @@ test('should correctly calculate results according to model options', async () = time: 1200, }); + // Set default units + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, 'globalOptions'); + + // Get calculate result function + const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; + + // Assert result is correct + let result = calculateResult({ type: 'time', time: 2495 }); + expect(result.key).to.equal('10.00 km'); + expect(result.value).to.equal('41:35'); + expect(result.pace).to.equal('4:09 / km'); + expect(result.result).to.equal('key'); + expect(result.sort).to.equal(2495); + + // Change default units + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'imperial', // changed from metric + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, 'globalOptions'); + + // Assert result is correct + result = calculateResult({ type: 'time', time: 2495 }); + expect(result.key).to.equal('6.21 mi'); + expect(result.value).to.equal('41:35'); + expect(result.pace).to.equal('6:41 / mi'); + expect(result.result).to.equal('key'); + expect(result.sort).to.equal(2495); + // Switch model await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - model: 'RiegelModel', // changed from the Riegel Model - riegelExponent: 1.06, - selectedTargetSet: '_race_targets', - }, 'options'); + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'RiegelModel', // changed from the Average Model + riegelExponent: 1.06, + }, + }, 'globalOptions'); // Calculate result - const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; - let result = calculateResult({ + result = calculateResult({ distanceValue: 10, distanceUnit: 'kilometers', type: 'distance', @@ -162,10 +350,12 @@ 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 - selectedTargetSet: '_race_targets', - }, 'options'); + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'RiegelModel', + riegelExponent: 1, // changed from 1.06 + }, + }, 'globalOptions'); // Calculate result result = calculateResult({ @@ -178,113 +368,10 @@ test('should correctly calculate results according to model options', async () = expect(result.value).to.equal('40:00.00'); }); -test('should load input race from localStorage', async () => { - // Initialize localStorage - 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: 'pace-input' }).vm.modelValue).to.deep.equal({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - }); -}); - -test('should save input race to localStorage', async () => { - // Initialize component - const wrapper = shallowMount(RaceCalculator); - - // Enter input race data - await wrapper.findComponent({ name: 'pace-input' }).setValue({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - }); - - // Assert data saved to localStorage - expect(localStorage.getItem('running-tools.race-calculator-input')).to.equal(JSON.stringify({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - })); -}); - -test('should save default units setting to localStorage when modified', async () => { - // Initialize component - const wrapper = shallowMount(RaceCalculator); - - // Change default units - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('metric', 'defaultUnitSystem'); - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('imperial', 'defaultUnitSystem'); - - // New default units should be saved to localStorage - expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"'); -}); - -test('should load options from localStorage', async () => { - // Initialize localStorage - const targetSet2 = { - name: 'Race targets #2', - targets: [ - { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, - { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, - { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, - ], - }; - localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({ - '_race_targets': { - name: 'Race targets #1', - targets: [ - { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, - { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, - { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, - ], - }, - 'B': targetSet2, - })); - localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, - selectedTargetSet: 'B', - })); - - // Initialize component - const wrapper = shallowMount(RaceCalculator); - - // Assert data loaded - expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ - model: 'PurdyPointsModel', - riegelExponent: 1.2, - selectedTargetSet: 'B', - }); - expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) - .to.deep.equal(targetSet2.targets); -}); - -test('should save options to localStorage when modified', async () => { +test('should correctly set AdvancedOptionsInput type prop', async () => { // Initialize component const wrapper = shallowMount(RaceCalculator); - // Update options - await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - 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, - selectedTargetSet: 'B', - })); + // Assert type prop is correctly set + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.type).to.equal('race'); }); diff --git a/tests/unit/views/SplitCalculator.spec.js b/tests/unit/views/SplitCalculator.spec.js @@ -1,13 +1,60 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import SplitCalculator from '@/views/SplitCalculator.vue'; +import { defaultTargetSets } from '@/core/targets'; +import { detectDefaultUnitSystem } from '@/core/units'; beforeEach(() => { localStorage.clear(); -}) +}); + +test('should initialize options to default values', async () => { + // Initialize component + const wrapper = shallowMount(SplitCalculator); + + // Assert options are initialized + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions + .defaultUnitSystem).to.equal(detectDefaultUnitSystem()); + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.defaultUnitSystem) + .to.equal(detectDefaultUnitSystem()); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + selectedTargetSet: '_split_targets', + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.targetSets) + .to.deep.equal({ _split_targets: defaultTargetSets._split_targets }); + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue) + .to.deep.equal(defaultTargetSets._split_targets.targets); +}); + +test('should load options from localStorage', async () => { + const targetSets = { + '_split_targets': { + name: 'Split targets', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + 'B': { + name: 'Split targets #2', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, + ], + }, + }; -test('should load selected target set from localStorage', async () => { // Initialize localStorage + localStorage.setItem('running-tools.global-options', JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + })); + localStorage.setItem('running-tools.split-calculator-target-sets', JSON.stringify(targetSets)); localStorage.setItem('running-tools.split-calculator-options', JSON.stringify({ selectedTargetSet: 'B', })); @@ -15,15 +62,22 @@ test('should load selected target set from localStorage', async () => { // Initialize component const wrapper = shallowMount(SplitCalculator); - // Assert selection is loaded + // Assert options are loaded + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions + .defaultUnitSystem).to.equal('imperial'); + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.defaultUnitSystem) + .to.equal('imperial'); expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ selectedTargetSet: 'B', }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.targetSets) + .to.deep.equal(targetSets); + expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue) + .to.deep.equal(targetSets.B.targets); }); -test('should load targets from localStorage and pass to splitOutputTable', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.split-calculator-target-sets', JSON.stringify({ +test('should save options to localStorage when modified', async () => { + const targetSets1 = { '_split_targets': { name: 'Split targets', targets: [ @@ -40,32 +94,89 @@ test('should load targets from localStorage and pass to splitOutputTable', async { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, ], }, - })); + }; + const targetSets2 = { + '_split_targets': { + name: 'Split targets', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + ], + }, + 'B': { + name: 'Split targets #2', + targets: [ + // split times modified: + { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 185 }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 195 }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 205 }, + ], + }, + }; // Initialize component const wrapper = shallowMount(SplitCalculator); - // Assert default split targets are initially loaded - expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ - selectedTargetSet: '_split_targets', - }); - expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([ - { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, - { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, - { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, - ]); + // Set default units setting + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, 'globalOptions'); + + // New default units should be saved to localStorage + expect(localStorage.getItem('running-tools.global-options')).to.equal(JSON.stringify({ + defaultUnitSystem: 'metric', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + })); - // Select a new target set + // Update default units setting + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }, 'globalOptions'); + + // New default units should be saved to localStorage + expect(localStorage.getItem('running-tools.global-options')).to.equal(JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + })); + + // Update target sets and selected target set via AdvancedOptionsInput + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue(targetSets1, + 'targetSets'); await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ selectedTargetSet: 'B', }, 'options'); - // Assert new target set is loaded - expect(wrapper.findComponent({ name: 'split-output-table' }).vm.modelValue).to.deep.equal([ - { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, - ]); + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.split-calculator-target-sets')) + .to.equal(JSON.stringify(targetSets1)); + expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal(JSON.stringify({ + selectedTargetSet: 'B', + })); + + // Update target sets via SplitOutputTable + await wrapper.findComponent({ name: 'split-output-table' }).setValue(targetSets2.B.targets); + + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.split-calculator-target-sets')) + .to.equal(JSON.stringify(targetSets2)); + expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal(JSON.stringify({ + selectedTargetSet: 'B', + })); }); test('should correctly handle null target set', async () => { @@ -98,100 +209,6 @@ test('should correctly handle null target set', async () => { ]); }); -test('should update targets in localStorage when modified by splitOutputTable', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.split-calculator-target-sets', JSON.stringify({ - '_split_targets': { - name: 'Split targets', - targets: [ - { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 180 }, - { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 180 }, - ], - }, - })); - - // Initialize component - const wrapper = shallowMount(SplitCalculator); - - // Update split times - await wrapper.findComponent({ name: 'split-output-table' }).setValue([ - { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, - ]); - - // Assert targets correctly saved in localStorage - expect(localStorage.getItem('running-tools.split-calculator-target-sets')).to.equal(JSON.stringify({ - '_split_targets': { - name: 'Split targets', - targets: [ - { type: 'distance', distanceValue: 1, distanceUnit: 'kilometers', split: 180 }, - { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers', split: 190 }, - { type: 'distance', distanceValue: 3000, distanceUnit: 'meters', split: 200 }, - ], - }, - })); -}); - -test('should save selected target set to localStorage when modified', async () => { - // Initialize component - const wrapper = shallowMount(SplitCalculator); - - // Select a new target set - await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - selectedTargetSet: '_race_targets', - }, 'options'); - - // New selected target set should be saved to localStorage - expect(localStorage.getItem('running-tools.split-calculator-options')).to.equal(JSON.stringify({ - selectedTargetSet: '_race_targets', - })); -}); - -test('should load default units from localStorage and pass to splitOutputTable', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.default-unit-system', '"metric"'); - - // Initialize component - const wrapper = shallowMount(SplitCalculator); - - // Assert default units setting is initialy loaded - expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.defaultUnitSystem) - .to.equal('metric'); - - // Assert prop is correct - expect(wrapper.findComponent({ name: 'split-output-table' }).vm.defaultUnitSystem) - .to.equal('metric'); - - // Change default units - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('imperial', 'defaultUnitSystem'); - - // Assert prop is correct - expect(wrapper.findComponent({ name: 'split-output-table' }).vm.defaultUnitSystem) - .to.equal('imperial'); -}); - -test('should save default units setting to localStorage when modified', async () => { - // Initialize component - const wrapper = shallowMount(SplitCalculator); - - // Set default units setting - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('metric', 'defaultUnitSystem'); - - // New default units should be saved to localStorage - expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"metric"'); - - // Set default units setting - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('imperial', 'defaultUnitSystem'); - - // New default units should be saved to localStorage - expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"'); -}); - test('should correctly set AdvancedOptionsInput type prop', async () => { // Initialize component const wrapper = shallowMount(SplitCalculator); diff --git a/tests/unit/views/WorkoutCalculator.spec.js b/tests/unit/views/WorkoutCalculator.spec.js @@ -2,10 +2,261 @@ import { beforeEach, test, expect } from 'vitest'; import { shallowMount } from '@vue/test-utils'; import WorkoutCalculator from '@/views/WorkoutCalculator.vue'; import { defaultTargetSets } from '@/core/targets'; +import { detectDefaultUnitSystem } from '@/core/units'; beforeEach(() => { localStorage.clear(); -}) +}); + +test('should initialize options to default values', async () => { + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Assert options are initialized + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions).to.deep.equal({ + defaultUnitSystem: detectDefaultUnitSystem(), + racePredictionOptions: { + model: 'AverageModel', + riegelExponent: 1.06, + }, + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + customTargetNames: false, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, + selectedTargetSet: '_workout_targets', + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.targetSets) + .to.deep.equal({ _workout_targets: defaultTargetSets._workout_targets }); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(defaultTargetSets._workout_targets.targets); +}); + +test('should load options from localStorage', async () => { + const targetSets = { + '_workout_targets': { + name: 'Workout targets #1', + targets: [ + { + splitValue: 400, splitUnit: 'meters', + type: 'distance', distanceValue: 1, distanceUnit: 'miles', + }, + { + splitValue: 800, splitUnit: 'meters', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', + }, + { + splitValue: 1600, splitUnit: 'meters', + type: 'time', time: 3600, + }, + { + splitValue: 2, splitUnit: 'miles', + type: 'time', time: 7200, + }, + ], + }, + 'B': { + name: 'Workout targets #2', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + distanceUnit: 'kilometers', distanceValue: 5, + splitUnit: 'miles', splitValue: 1, + type: 'distance' + }, + ], + }, + }; + + // Initialize localStorage + localStorage.setItem('running-tools.global-options', JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + })); + localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify(targetSets)); + localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ + customTargetNames: true, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + })); + + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Assert options are loaded + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.globalOptions).to.deep.equal({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'PurdyPointsModel', + riegelExponent: 1.2, + }, + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ + customTargetNames: true, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + }); + expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.targetSets) + .to.deep.equal(targetSets); + expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) + .to.deep.equal(targetSets.B.targets); +}); + +test('should save options to localStorage when modified', async () => { + const targetSets = { + '_workout_targets': { + name: 'Workout targets #1', + targets: [ + { + splitValue: 400, splitUnit: 'meters', + type: 'distance', distanceValue: 1, distanceUnit: 'miles', + }, + { + splitValue: 800, splitUnit: 'meters', + type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', + }, + { + splitValue: 1600, splitUnit: 'meters', + type: 'time', time: 3600, + }, + { + splitValue: 2, splitUnit: 'miles', + type: 'time', time: 7200, + }, + ], + }, + 'B': { + name: 'Workout targets #2', + targets: [ + { + distanceUnit: 'miles', distanceValue: 2, + splitUnit: 'meters', splitValue: 400, + type: 'distance', + }, + { + time: 6000, + splitUnit: 'kilometers', splitValue: 2, + type: 'time', + }, + { + distanceUnit: 'kilometers', distanceValue: 5, + splitUnit: 'miles', splitValue: 1, + type: 'distance' + }, + ], + }, + }; + + // Initialize component + const wrapper = shallowMount(WorkoutCalculator); + + // Update options + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.3, + }, + }, 'globalOptions'); + + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.global-options')).to.equal(JSON.stringify({ + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'CameronModel', + riegelExponent: 1.3, + }, + })); + + // Update input race + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }); + + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({ + customTargetNames: false, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: '_workout_targets', + })); + + // Update target name customization + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + customTargetNames: true, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: '_workout_targets', + }, 'options'); + + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({ + customTargetNames: true, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: '_workout_targets', + })); + + // Update target sets and selected target set + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue(targetSets, + 'targetSets'); + await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ + customTargetNames: true, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + }, 'options'); + + // Assert data saved to localStorage + expect(localStorage.getItem('running-tools.workout-calculator-target-sets')) + .to.equal(JSON.stringify(targetSets)); + expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({ + customTargetNames: true, + input: { + distanceValue: 1, + distanceUnit: 'miles', + time: 600, + }, + selectedTargetSet: 'B', + })); +}); test('should correctly predict workout splits', async () => { // Initialize component @@ -39,8 +290,11 @@ test('should correctly handle null target set', async () => { // Switch to invalid target set await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: 'does_not_exist', }, 'options'); @@ -50,8 +304,11 @@ test('should correctly handle null target set', async () => { // Switch to valid target set await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ customTargetNames: false, - model: 'AverageModel', - riegelExponent: 1.06, + input: { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, + }, selectedTargetSet: '_workout_targets', }, 'options'); @@ -61,24 +318,25 @@ test('should correctly handle null target set', async () => { .to.deep.equal(workoutTargets); }); -test('should correctly calculate results according to advanced model options', async () => { +test('should correctly calculate results according to options', async () => { // Initialize component const wrapper = shallowMount(WorkoutCalculator); // Enter input race data await wrapper.findComponent({ name: 'pace-input' }).setValue({ - distanceValue: 5, - distanceUnit: 'kilometers', - time: 1200, + distanceValue: 2, + distanceUnit: 'miles', + time: 630, }); - // Update model and Riegel Exponent + // Update model and Riegel exponent await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ - customTargetNames: false, - model: 'RiegelModel', - riegelExponent: 1.10, - selectedTargetSet: '_workout_targets', - }, 'options'); + defaultUnitSystem: 'imperial', + racePredictionOptions: { + model: 'RiegelModel', + riegelExponent: 1.10, + }, + }, 'globalOptions'); // Calculate result const calculateResult = wrapper.findComponent({ name: 'single-output-table' }).vm.calculateResult; @@ -90,147 +348,29 @@ test('should correctly calculate results according to advanced model options', a // Assert result is correct expect(result.key).to.equal('1 km @ 10 km'); - expect(result.value).to.equal('4:17.23'); -}); + expect(result.value).to.equal('3:39.23'); -test('should load input race from localStorage', async () => { - // Initialize localStorage - localStorage.setItem('running-tools.workout-calculator-input', JSON.stringify({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - })); - - // Initialize component - const wrapper = shallowMount(WorkoutCalculator); - - // Assert data loaded - expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - }); -}); - -test('should save input race to localStorage', async () => { - // Initialize component - const wrapper = shallowMount(WorkoutCalculator); - - // Enter input race data - await wrapper.findComponent({ name: 'pace-input' }).setValue({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - }); - - // Assert data saved to localStorage - expect(localStorage.getItem('running-tools.workout-calculator-input')).to.equal(JSON.stringify({ - distanceValue: 1, - distanceUnit: 'miles', - time: 600, - })); -}); - -test('should save default units setting to localStorage when modified', async () => { - // Initialize component - const wrapper = shallowMount(WorkoutCalculator); - - // Change default units - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('metric', 'defaultUnitSystem'); - await wrapper.findComponent({ name: 'advanced-options-input' }) - .setValue('imperial', 'defaultUnitSystem'); - - // New default units should be saved to localStorage - expect(localStorage.getItem('running-tools.default-unit-system')).to.equal('"imperial"'); -}); - -test('should load options from localStorage', async () => { - // Initialize localStorage - const targetSet2 = { - name: 'Workout targets #2', - targets: [ - { - distanceUnit: 'miles', distanceValue: 2, - splitUnit: 'meters', splitValue: 400, - type: 'distance', - }, - { - time: 6000, - splitUnit: 'kilometers', splitValue: 2, - type: 'time', - }, - { - distanceUnit: 'kilometers', distanceValue: 5, - splitUnit: 'miles', splitValue: 1, - type: 'distance' - }, - ], - }; - localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({ - '_workout_targets': { - name: 'Workout targets #1', - targets: [ - { - splitValue: 400, splitUnit: 'meters', - type: 'distance', distanceValue: 1, distanceUnit: 'miles', - }, - { - splitValue: 800, splitUnit: 'meters', - type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', - }, - { - splitValue: 1600, splitUnit: 'meters', - type: 'time', time: 3600, - }, - { - splitValue: 2, splitUnit: 'miles', - type: 'time', time: 7200, - }, - ], - }, - 'B': targetSet2, - })); - localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ - customTargetNames: true, - model: 'PurdyPointsModel', - riegelExponent: 1.2, - selectedTargetSet: 'B', - })); - - // Initialize component - const wrapper = shallowMount(WorkoutCalculator); - - // Assert data loaded - expect(wrapper.findComponent({ name: 'advanced-options-input' }).vm.options).to.deep.equal({ - customTargetNames: true, - model: 'PurdyPointsModel', - riegelExponent: 1.2, - selectedTargetSet: 'B', - }); - expect(wrapper.findComponent({ name: 'single-output-table' }).vm.targets) - .to.deep.equal(targetSet2.targets); -}); - -test('should save options to localStorage when modified', async () => { - // Initialize component - const wrapper = shallowMount(WorkoutCalculator); - - // Update options + // Update target name customization await wrapper.findComponent({ name: 'advanced-options-input' }).setValue({ customTargetNames: true, - model: 'CameronModel', - riegelExponent: 1.3, - selectedTargetSet: 'B', + input: { + distanceValue: 2, + distanceUnit: 'miles', + time: 630, + }, + selectedTargetSet: '_workout_targets', }, 'options'); - // Assert data saved to localStorage - expect(localStorage.getItem('running-tools.workout-calculator-options')).to.equal(JSON.stringify({ - customTargetNames: true, - model: 'CameronModel', - riegelExponent: 1.3, - selectedTargetSet: 'B', - })); + // Calculate result + result = calculateResult({ + customName: 'foo', + splitValue: 1, splitUnit: 'kilometers', + type: 'distance', distanceValue: 10, distanceUnit: 'kilometers', + }); + + // Assert result is correct + expect(result.key).to.equal('foo'); + expect(result.value).to.equal('3:39.23'); }); test('should correctly set AdvancedOptionsInput type prop', async () => {