running-tools

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

commit fd20d81e5efd33fcbbdc435ed6750a1c3d6aadbb
parent a11031a0646507e537e4b021e003bcf7b169032c
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sun, 29 Jun 2025 18:14:19 -0700

Merge branch 'dev' into typescript

Diffstat:
Msrc/views/UnitCalculator.vue | 4++--
Mtests/e2e/cross-calculator.spec.js | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/e2e/unit-calculator.spec.js | 8++++----
3 files changed, 333 insertions(+), 6 deletions(-)

diff --git a/src/views/UnitCalculator.vue b/src/views/UnitCalculator.vue @@ -35,7 +35,7 @@ </template> <script setup lang="ts"> -import { computed, ref } from 'vue'; +import { computed } from 'vue'; import { formatDuration, formatNumber } from '@/utils/format'; import * as unitUtils from '@/utils/units'; @@ -109,7 +109,7 @@ const inputs = useStorage<UnitCalculatorInputs>('unit-calculator-inputs', { /* * The unit category */ -const category = ref<UnitTypes>(UnitTypes.Distance); +const category = useStorage<UnitTypes>('unit-calculator-category', UnitTypes.Distance); /* * The inputs for the current category diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js @@ -1,6 +1,12 @@ import { test, expect } from '@playwright/test'; test('Cross-calculator', async ({ page }) => { + // Structure: + // - Set various options in the different calculators + // - Go back and assert the options are not reset + // - Assert localStorage entries are correct + // - Reload app and assert the options are loaded + // Go to batch calculator await page.goto('/'); await page.getByRole('button', { name: 'Batch Calculator' }).click(); @@ -158,6 +164,298 @@ test('Cross-calculator', async ({ page }) => { await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); await expect(page.getByRole('row')).toHaveCount(16); + // Reset selected calculator + await page.getByLabel('Calculator').selectOption('Race Calculator'); + + // Return to pace calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Pace Calculator' }).click(); + + // Assert paces are correct (input pace not reset) + await expect(page.getByRole('row').nth(1)).toHaveText('0.4 km' + '1:55.57'); + await expect(page.getByRole('row').nth(2)).toHaveText('800 m' + '3:51.15'); + await expect(page.getByRole('row').nth(3)).toHaveText('2.08 km' + '10:00'); + await expect(page.getByRole('row')).toHaveCount(4); + + // Return to race calculator + await page.getByRole('link', { name: 'Back' }).click(); + 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.86' + '3:21 / km'); + await expect(page.getByRole('row')).toHaveCount(17); + + // Return to split calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Split Calculator' }).click(); + + // 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'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(1)).toHaveText('13:30.00'); + await expect(page.getByRole('row').nth(2).getByRole('cell').nth(3)).toHaveText('4:04 / km'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(1)).toHaveText('20:00.00'); + await expect(page.getByRole('row').nth(3).getByRole('cell').nth(3)).toHaveText('3:37 / km'); + await expect(page.getByRole('row')).toHaveCount(4); + + // Return to unit calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Unit Calculator' }).click(); + + // Assert result is correct (state not reset) + await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366'); + + // Return to workout calculator + await page.getByRole('link', { name: 'Back' }).click(); + await page.getByRole('button', { name: 'Workout Calculator' }).click(); + + // 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')).toHaveCount(5); + + // Assert general localStorage entries are correct + expect(await page.evaluate(() => localStorage.length)).toEqual(18); + expect(await page.evaluate(() => localStorage.getItem('running-tools.default-unit-system'))) + .toEqual(JSON.stringify('metric')); + + // 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, + 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-target-set')))); + expect(paceCalculatorKey - parseInt(Date.now().toString())).toBeLessThan(100000); + expect(await page.evaluate(() => localStorage.getItem('running-tools.pace-calculator-target-sets'))) + .toEqual(JSON.stringify({ + _pace_targets: { + name: 'Common Pace Targets', + targets: [ + { type: 'distance', distanceValue: 100, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 200, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 300, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 600, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1000, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1200, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1500, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 3200, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 4, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 6, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 8, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 6, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 8, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 0.5, distanceUnit: 'marathons' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'marathons' }, + { type: 'time', time: 600 }, + { type: 'time', time: 1800 }, + { type: 'time', time: 3600 }, + ], + }, + [paceCalculatorKey.toString()]: { + name: '800m Splits', + targets: [ + { type: 'distance', distanceValue: 0.4, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'time', time: 600 }, + ], + }, + })); + + // 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, + })); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.race-calculator-target-set'))) + .toEqual(JSON.stringify('_race_targets')); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.race-calculator-target-sets'))).toEqual(JSON.stringify({ + _race_targets: { + name: 'Common Race Targets', + targets: [ + { type: 'distance', distanceValue: 400, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 800, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1500, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1600, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3000, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 3200, distanceUnit: 'meters' }, + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 6, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 8, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 10, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 15, distanceUnit: 'kilometers' }, + { type: 'distance', distanceValue: 0.5, distanceUnit: 'marathons' }, + { type: 'distance', distanceValue: 1, distanceUnit: 'marathons' }, + ], + }, + })); + + // Assert localStorage entries for the split calculator are correct + expect(await page.evaluate(() => + localStorage.getItem('running-tools.split-calculator-target-set'))) + .toEqual(JSON.stringify('_split_targets')); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.split-calculator-target-sets'))).toEqual(JSON.stringify({ + _split_targets: { + name: '5K 1600m Splits', + targets: [ + { type: 'distance', distanceValue: 1.6, distanceUnit: 'kilometers', splitTime: 420 }, + { type: 'distance', distanceValue: 3.2, distanceUnit: 'kilometers', splitTime: 390 }, + { type: 'distance', distanceValue: 5, distanceUnit: 'kilometers', splitTime: 390 }, + ], + }, + })); + + // Assert localStorage entries for the unit calculator are correct + expect(await page.evaluate(() => localStorage.getItem('running-tools.unit-calculator-category'))) + .toEqual(JSON.stringify('speed_and_pace')); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.unit-calculator-inputs'))).toEqual(JSON.stringify({ + distance: { + inputValue: 1, + inputUnit: 'miles', + outputUnit: 'kilometers', + }, + time: { + inputValue: 1, + inputUnit: 'seconds', + outputUnit: 'hh:mm:ss', + }, + speed_and_pace: { + inputValue: 10, + inputUnit: 'kilometers_per_hour', + outputUnit: 'seconds_per_mile', + }, + })); + + // 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, + })); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.workout-calculator-target-set'))) + .toEqual(JSON.stringify('_workout_targets')); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.workout-calculator-target-sets'))).toEqual(JSON.stringify({ + _workout_targets: { + name: 'Common Workout Targets', + 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: 1, splitUnit: 'miles', + type: 'distance', distanceValue: 1, distanceUnit: 'marathons', + }, + ], + }, + })); + + // Reload app and go to batch calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Batch Calculator' }).click(); + + // Assert pace 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')).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')).toHaveCount(17); + await expect(page.getByRole('row')).toHaveCount(16); + + // Assert pace results are correct (inputs and options not reset, new pace targets loaded) + await page.getByLabel('Calculator').selectOption('Pace Calculator'); + 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); + 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:37'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(4); + 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:11'); + 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) + await page.getByLabel('Calculator').selectOption('Workout Calculator'); + await expect(page.getByLabel('Target name customization')).toHaveValue("true"); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(2)).toHaveText('800 m @ 5 km'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(5); + 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')).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')).toHaveCount(5); + await expect(page.getByRole('row')).toHaveCount(16); + + // Reset selected calculator + await page.getByLabel('Calculator').selectOption('Race Calculator'); + // Return to pace calculator await page.getByRole('link', { name: 'Back' }).click(); await page.getByRole('button', { name: 'Pace Calculator' }).click(); @@ -206,3 +504,32 @@ test('Cross-calculator', async ({ page }) => { await expect(page.getByRole('row').nth(3)).toHaveText('1600 m @ 1:00:00' + '5:53.56'); await expect(page.getByRole('row')).toHaveCount(5); }); + +test('v1.4.1 Migration', async ({ page }) => { + // Structure: + // - Set v1.4.1 localStorage entries + // - Reload app and assert the proper migrations were performed + + // Set v1.4.1 localStorage options + await page.goto('/'); + await page.evaluate(() => + localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ + // No customTargetNames property + model: 'VO2MaxModel', + riegelExponent: 1.06, + }))); + + // Reload the app and assert localStorage is updated + await page.goto('/'); + expect(await page.evaluate(() => + localStorage.getItem('running-tools.workout-calculator-options'))).toEqual(JSON.stringify({ + model: 'VO2MaxModel', + riegelExponent: 1.06, + customTargetNames: false, + })); + + // Assert target name customization is disabled by default + await page.getByRole('button', { name: 'Workout Calculator' }).click(); + await page.getByText('Advanced Options').click(); + await expect(page.getByLabel('Target name customization')).toHaveValue('false'); +}); diff --git a/tests/e2e/unit-calculator.spec.js b/tests/e2e/unit-calculator.spec.js @@ -55,13 +55,13 @@ test('Unit Calculator', async ({ page }) => { // Reload page await page.reload(); - // Assert distance result is correct (state not reset) - await expect(page.getByLabel('Output value')).toHaveText('3.107'); - // Assert time result is correct (state not reset) - await page.getByLabel('Selected unit category').selectOption('Time'); await expect(page.getByLabel('Output value')).toHaveText('24872.100'); + // Assert distance result is correct (state not reset) + await page.getByLabel('Selected unit category').selectOption('Distance'); + await expect(page.getByLabel('Output value')).toHaveText('3.107'); + // Assert speed & pace result is correct (state not reset) await page.getByLabel('Selected unit category').selectOption('Speed & Pace'); await expect(page.getByLabel('Output value')).toHaveText('00:09:39.366');