running-tools

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

commit 7ba09be38250b4360d800309a9dc15b0612fc117
parent 0ad27a6907f79aaebb3cf7a8c7d9c5e2c71a79a8
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat, 23 Aug 2025 14:20:43 -0700

Update localStorage migrations and related tests

Diffstat:
Asrc/core/migrations.ts | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/core/utils.ts | 68--------------------------------------------------------------------
Msrc/main.ts | 2+-
Mtests/e2e/cross-calculator.spec.js | 146++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Atests/unit/core/migration.spec.js | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/unit/core/utils.spec.js | 107++++++++++++++++---------------------------------------------------------------
6 files changed, 465 insertions(+), 190 deletions(-)

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/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/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js @@ -726,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(() => @@ -807,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(() => @@ -851,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', @@ -861,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'))) @@ -888,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(); @@ -931,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); @@ -944,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); @@ -957,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'); @@ -978,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'); @@ -988,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'); @@ -997,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'); @@ -1017,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/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/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'); }); });