running-tools

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

commit ef6f238edfc7ce3ad0ed686f22eaf1ba051ef2f9
parent 78a17ce7d3cf1ab7439d190a133fc8247d406924
Author: Asher Morgan <59518073+ashermorgan@users.noreply.github.com>
Date:   Sat, 29 Jun 2024 16:38:14 -0700

Merge pull request #10 from ashermorgan/batch-calculator

Add batch calculator
Diffstat:
MREADME.md | 2++
Msrc/assets/target-calculator.css | 2++
Asrc/components/DoubleOutputTable.vue | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/router/index.js | 10++++++++++
Msrc/views/AboutPage.vue | 19+++++++++++++++++--
Asrc/views/BatchCalculator.vue | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/views/HomePage.vue | 10++++++----
Atests/e2e/batch-calculator.spec.js | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/e2e/cross-calculator.spec.js | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/components/DoubleOutputTable.spec.js | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Atests/unit/views/BatchCalculator.spec.js | 279+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 846 insertions(+), 6 deletions(-)

diff --git a/README.md b/README.md @@ -3,6 +3,8 @@ A collection of tools for runners and their coaches. Try it out [here](https://ashermorgan.github.io/running-tools/). ## Features +- [Batch Calculator](https://ashermorgan.github.io/running-tools/#/calculate/batch): + Create tables of the results of the other calculators over a range of inputs - [Pace Calculator](https://ashermorgan.github.io/running-tools/#/calculate/paces): Calculate distances and times that are at the same pace - [Race Calculator](https://ashermorgan.github.io/running-tools/#/calculate/races): diff --git a/src/assets/target-calculator.css b/src/assets/target-calculator.css @@ -36,6 +36,8 @@ details > * { /* calculator output */ .output { min-width: 300px; + max-width: 100%; + overflow: auto; } @media only screen and (max-width: 500px) { .output { diff --git a/src/components/DoubleOutputTable.vue b/src/components/DoubleOutputTable.vue @@ -0,0 +1,104 @@ +<template> + <div class="double-target-table"> + <table class="results"> + <thead> + <tr> + <th v-for="(col, x) in results[0]" :key="x"> + {{ col }} + </th> + </tr> + </thead> + + <tbody> + <tr v-for="(row, y) in results.slice(1)" :key="y"> + <td v-for="(col, x) in row" :key="x"> + {{ col }} + </td> + </tr> + + <tr v-if="results.length === 1" class="empty-message"> + <td colspan="4"> + No inputs were specified. + </td> + </tr> + </tbody> + </table> + </div> +</template> + +<script setup> +import { computed } from 'vue'; +import { formatDuration, formatNumber } from '@/utils/format'; +import { DISTANCE_UNITS } from '@/utils/units'; + +const props = defineProps({ + /** + * The method that generates the target table rows + */ + calculateResult: { + type: Function, + required: true, + }, + + /** + * The target set + */ + targets: { + type: Array, + default: () => [], + }, + + /** + * The set of input times + */ + inputTimes: { + type: Array, + default: () => [], + }, + + /** + * The input distance + */ + inputDistance: { + type: Object, + default: () => ({ + distanceValue: 5, + distanceUnit: 'kilometers', + }), + } +}); + +/** + * The target table results + */ +const results = computed(() => { + // Calculate results + const results = [[ + formatNumber(props.inputDistance.distanceValue, 0, 2, false) + ' ' + + DISTANCE_UNITS[props.inputDistance.distanceUnit].symbol + ]]; + + props.inputTimes.forEach((input, y) => { + let row = [formatDuration(input, 3, 2, false)]; + + props.targets.forEach(target => { + let result = props.calculateResult({ ...props.inputDistance, time: input }, target); + + if (y === 0) { + results[0].push(result[result.result === 'key' ? 'value' : 'key']); + } + + row.push(result[result.result]); + }); + results.push(row); + }); + return results; +}); +</script> + +<style scoped> +table th, table td { + /* Add more space between table cells */ + padding: 0.2em 0.5em; +} +</style> diff --git a/src/router/index.js b/src/router/index.js @@ -1,6 +1,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'; import HomePage from '@/views/HomePage.vue'; import AboutPage from '@/views/AboutPage.vue'; +import BatchCalculator from '@/views/BatchCalculator.vue'; import PaceCalculator from '@/views/PaceCalculator.vue'; import RaceCalculator from '@/views/RaceCalculator.vue'; import SplitCalculator from '@/views/SplitCalculator.vue'; @@ -38,6 +39,15 @@ const router = createRouter({ redirect: '/home', }, { + path: '/calculate/batch', + name: 'calculate-batch', + component: BatchCalculator, + meta: { + title: 'Batch Calculator', + back: 'home', + }, + }, + { path: '/calculate/paces', name: 'calculate-paces', component: PaceCalculator, diff --git a/src/views/AboutPage.vue b/src/views/AboutPage.vue @@ -19,7 +19,23 @@ </p> <h2>The Calculators</h2> - <p>Running Tools contains five calculators:</p> + <p>Running Tools contains six calculators:</p> + + <h3>Batch Calculator</h3> + <p> + The <router-link :to="{ name: 'calculate-batch' }">Batch Calculator</router-link> calculates + results for a range of input times using the Pace, Race, or Workout Calculators. + Options such as the default unit system, selected target set, and race prediction model are + automatically loaded from the settings of the active calculator. + </p> + <p> + The Batch Calculator is useful for tasks such as: + </p> + <ul class="questions"> + <li>Generating a table of mile splits and the corresponding marathon finish times.</li> + <li>Generating a table of equivalent race results for many distances and speeds.</li> + <li>Generating a table of workout split times for an entire team.</li> + </ul> <h3>Pace Calculator</h3> <p> @@ -36,7 +52,6 @@ <li>What do I have to run per mile to finish a marathon in three hours? (6:52 per mile)</li> </ul> - <h3>Race Calculator</h3> <p> The <router-link :to="{ name: 'calculate-races' }">Race Calculator</router-link> takes a diff --git a/src/views/BatchCalculator.vue b/src/views/BatchCalculator.vue @@ -0,0 +1,163 @@ +<template> + <div class="calculator"> + <h2>Batch Input</h2> + <div class="input"> + <pace-input v-model="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"/> + &times; + <integer-input v-model="options.rows" min="1" aria-label="Number of rows"/> + </div> + <div> + Calculator: + <select aria-label="Calculator" v-model="options.calculator"> + <option value="pace">Pace Calculator</option> + <option value="race">Race Calculator</option> + <option value="workout">Workout Calculator</option> + </select> + </div> + </div> + + <h2>Batch Results</h2> + <double-output-table class="output" :input-times="inputTimes" :input-distance="inputDistance" + :calculate-result="calculateResult" + :targets="targetSets[selectedTargetSet] ? targetSets[selectedTargetSet].targets : []"/> + </div> +</template> + +<script setup> +import { computed } from 'vue'; + +import * as calcUtils from '@/utils/calculators'; +import { defaultTargetSets } from '@/utils/targets'; +import { detectDefaultUnitSystem } from '@/utils/units'; + +import DoubleOutputTable from '@/components/DoubleOutputTable.vue'; +import IntegerInput from '@/components/IntegerInput.vue'; +import PaceInput from '@/components/PaceInput.vue'; +import TimeInput from '@/components/TimeInput.vue'; + +import useStorage from '@/composables/useStorage'; + +/** + * The input pace + */ +const input = useStorage('batch-calculator-input', { + distanceValue: 5, + distanceUnit: 'kilometers', + time: 1200, +}); + +/** + * The batch input options + */ +const options = useStorage('batch-calculator-options', { + calculator: 'workout', + increment: 15, + rows: 20, +}); + +/** + * The default unit system + */ +const defaultUnitSystem = useStorage('default-unit-system', detectDefaultUnitSystem()); + +/** + * The current selected target sets for each calculator + */ +const selectedPaceTargetSet = useStorage('pace-calculator-target-set', '_pace_targets'); +const selectedRaceTargetSet = useStorage('race-calculator-target-set', '_race_targets'); +const selectedWorkoutTargetSet = useStorage('workout-calculator-target-set', '_workout_targets'); + +/** + * The target sets for each calculator + */ +const paceTargetSets = useStorage('pace-calculator-target-sets', { + _pace_targets: defaultTargetSets._pace_targets +}); +const raceTargetSets = useStorage('race-calculator-target-sets', { + _race_targets: defaultTargetSets._race_targets +}); +const workoutTargetSets = useStorage('workout-calculator-target-sets', { + _workout_targets: defaultTargetSets._workout_targets +}); + +/** + * The advanced options for each calculator + */ +const raceOptions = useStorage('race-calculator-options', { + model: 'AverageModel', + riegelExponent: 1.06, +}); +const workoutOptions = useStorage('workout-calculator-options', { + model: 'AverageModel', + riegelExponent: 1.06, +}); + +/** + * The input distance + */ +const inputDistance = computed(() => ({ + distanceValue: input.value.distanceValue, + distanceUnit: input.value.distanceUnit, +})); + +/** + * The set of input times + */ +const inputTimes = computed(() => { + let results = []; + for (let i = 0; i < options.value.rows; i++) { + results.push(input.value.time + options.value.increment * i); + } + return results; +}); + +/** + * The selected target set for the current calculator + */ +const selectedTargetSet = computed(() => { + if (options.value.calculator === 'pace') { + return selectedPaceTargetSet.value; + } else if (options.value.calculator === 'race') { + return selectedRaceTargetSet.value; + } else { + return selectedWorkoutTargetSet.value; + } +}); + +/** + * The target sets for the current calculator + */ +const targetSets = computed(() => { + if (options.value.calculator === 'pace') { + return paceTargetSets.value; + } else if (options.value.calculator === 'race') { + return raceTargetSets.value; + } else { + return workoutTargetSets.value; + } +}); + +/** + * The appropriate calculate_results function for the current calculator + */ +const calculateResult = computed(() => { + if (options.value.calculator === 'pace') { + return (x,y) => calcUtils.calculatePaceResults(x, y, defaultUnitSystem.value); + } else if (options.value.calculator === 'race') { + return (x,y) => calcUtils.calculateRaceResults(x, y, raceOptions.value, defaultUnitSystem.value); + } else { + return (x,y) => calcUtils.calculateWorkoutResults(x, y, workoutOptions.value); + } +}); +</script> + +<style scoped> +@import '@/assets/target-calculator.css'; +</style> diff --git a/src/views/HomePage.vue b/src/views/HomePage.vue @@ -4,6 +4,11 @@ A collection of tools for runners and their coaches </p> <div class="calculators"> + <router-link :to="{ name: 'calculate-batch' }" v-slot="{ navigate }" custom> + <button @click="navigate"> + Batch Calculator + </button> + </router-link> <router-link :to="{ name: 'calculate-paces' }" v-slot="{ navigate }" custom> <button @click="navigate"> Pace Calculator @@ -29,7 +34,6 @@ Workout Calculator </button> </router-link> - <div class="card"></div> </div> <p class="about-link"> <router-link :to="{ name: 'about' }"> @@ -57,10 +61,8 @@ max-width: 39em; margin: 1em auto; } -.calculators > * { - width: 12em; -} .calculators button { + width: 12em; font-size: 1em; padding: 0.5em; } diff --git a/tests/e2e/batch-calculator.spec.js b/tests/e2e/batch-calculator.spec.js @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test'; + +test('Basic usage', async ({ page }) => { + await page.goto('/'); + + // Go to batch calculator + await page.getByRole('button', { name: 'Batch Calculator' }).click(); + await expect(page).toHaveTitle('Batch Calculator - Running Tools'); + + // Enter input pace (2 mi in 10:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('10'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Enter batch options (15 x 10s increments) + await page.getByLabel('Duration increment minutes').fill('0'); + await page.getByLabel('Duration increment seconds').fill('10'); + await page.getByLabel('Number of rows').fill('15'); + + // Assert workout results are correct + 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:41.21'); + 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:16.91'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row')).toHaveCount(16); + + // Change calculator + await expect(page.getByLabel('Calculator')).toHaveValue('workout'); + await page.getByLabel('Calculator').selectOption('Pace Calculator'); + + // Assert pace results are correct + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(0)).toHaveText('2 mi'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(6)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(28)).toHaveText('10:00'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2:36.58'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(28)).toHaveText('1.90 mi'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(6)).toHaveText('3:11.38'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(28)).toHaveText('1.56 mi'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row')).toHaveCount(16); + + // Change calculator + await page.getByLabel('Calculator').selectOption('Race Calculator'); + + // Assert race results are correct + 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:14.60'); + 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:43.61'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); + await expect(page.getByRole('row')).toHaveCount(16); +}); + +test('Save settings across page reloads', async ({ page }) => { + // Go to batch calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Batch Calculator' }).click(); + + // Enter input pace (2 mi in 10:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('10'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Enter batch options (15 x 10s increments) + await page.getByLabel('Duration increment minutes').fill('0'); + await page.getByLabel('Duration increment seconds').fill('10'); + await page.getByLabel('Number of rows').fill('15'); + + // Change calculator + await page.getByLabel('Calculator').selectOption('Pace Calculator'); + + // Reload page + await page.reload(); + + // 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(6)).toHaveText('800 m'); + await expect(page.getByRole('row').nth(0).getByRole('cell').nth(28)).toHaveText('10:00'); + await expect(page.getByRole('row').nth(0).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(0)).toHaveText('10:30'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(6)).toHaveText('2:36.58'); + await expect(page.getByRole('row').nth(1).getByRole('cell').nth(28)).toHaveText('1.90 mi'); + await expect(page.getByRole('row').nth(1).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(0)).toHaveText('12:50'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(6)).toHaveText('3:11.38'); + await expect(page.getByRole('row').nth(15).getByRole('cell').nth(28)).toHaveText('1.56 mi'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(31); + await expect(page.getByRole('row')).toHaveCount(16); +}); diff --git a/tests/e2e/cross-calculator.spec.js b/tests/e2e/cross-calculator.spec.js @@ -1,6 +1,25 @@ import { test, expect } from '@playwright/test'; test('Save and update state when navigating between calculators', async ({ page }) => { + // Go to batch calculator + await page.goto('/'); + await page.getByRole('button', { name: 'Batch Calculator' }).click(); + + // Enter input pace (2 mi in 10:30) + await page.getByLabel('Input distance value').fill('2'); + await page.getByLabel('Input distance unit').selectOption('Miles'); + await page.getByLabel('Input duration hours').fill('0'); + await page.getByLabel('Input duration minutes').fill('10'); + await page.getByLabel('Input duration seconds').fill('30'); + + // Enter batch options (15 x 10s increments) + await page.getByLabel('Duration increment minutes').fill('0'); + await page.getByLabel('Duration increment seconds').fill('10'); + await page.getByLabel('Number of rows').fill('15'); + + // Change calculator + await page.getByLabel('Calculator').selectOption('Pace Calculator'); + // Go to pace calculator await page.goto('/'); await page.getByRole('button', { name: 'Pace Calculator' }).click(); @@ -95,6 +114,48 @@ test('Save and update state when navigating between calculators', async ({ page // Change default units (should update on other calculators too) await page.getByLabel('Default units').selectOption('Kilometers'); + // 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, 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); + 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:36.58'); + 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.38'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(4); + await expect(page.getByRole('row')).toHaveCount(16); + + // Assert race results are correct (new race options loaded) + await page.getByLabel('Calculator').selectOption('Race 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(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.04'); + 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.05'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(17); + 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.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:41.93'); + 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:16.98'); + await expect(page.getByRole('row').nth(15).getByRole('cell')).toHaveCount(5); + await expect(page.getByRole('row')).toHaveCount(16); + // Return to pace calculator await page.getByRole('link', { name: 'Back' }).click(); await page.getByRole('button', { name: 'Pace Calculator' }).click(); diff --git a/tests/unit/components/DoubleOutputTable.spec.js b/tests/unit/components/DoubleOutputTable.spec.js @@ -0,0 +1,96 @@ +import { test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import DoubleOutputTable from '@/components/DoubleOutputTable.vue'; + +test('should correctly render table body rows and headers', () => { + // Initialize component + const results = [ + { key: 'key1', value: 'value1', pace: 'pace1', result: 'value', sort: 2 }, + { key: 'key2', value: 'value2', pace: 'pace2', result: 'value', sort: 1 }, + { key: 'key3', value: 'value3', pace: 'pace3', result: 'value', sort: 3 }, + + { key: 'key4', value: 'value4', pace: 'pace4', result: 'key', sort: 2 }, + { key: 'key5', value: 'value5', pace: 'pace5', result: 'key', sort: 1 }, + { key: 'key6', value: 'value6', pace: 'pace6', result: 'key', sort: 3 }, + + { key: 'key7', value: 'value7', pace: 'pace7', result: 'value', sort: 2 }, + { key: 'key8', value: 'value8', pace: 'pace8', result: 'value', sort: 1 }, + { key: 'key9', value: 'value9', pace: 'pace9', result: 'value', sort: 3 }, + ]; + const wrapper = shallowMount(DoubleOutputTable, { + propsData: { + calculateResult: (col, row) => { + expect(col.distanceUnit).to.equal('miles'); + expect(col.distanceValue).to.equal(2); + return results[row.id + 3*(col.time - 600)]; + }, + targets: [ + { id: 0 }, + { id: 1 }, + { id: 2 }, + ], + inputTimes: [ 600, 601, 602 ], + inputDistance: { + distanceUnit: 'miles', + distanceValue: 2, + }, + }, + }); + + // Assert headers are correctly generated from first row of results + const headers = wrapper.findAll('th'); + expect(headers[0].element.textContent).to.equal('2 mi'); + expect(headers[1].element.textContent).to.equal('key1'); + expect(headers[2].element.textContent).to.equal('key2'); + expect(headers[3].element.textContent).to.equal('key3'); + expect(headers.length).to.equal(4); + + // Assert results are correctly rendered + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].element.textContent).to.equal('10:00'); + expect(rows[0].findAll('td')[1].element.textContent).to.equal('value1'); + expect(rows[0].findAll('td')[2].element.textContent).to.equal('value2'); + expect(rows[0].findAll('td')[3].element.textContent).to.equal('value3'); + expect(rows[0].findAll('td').length).to.equal(4); + expect(rows[1].findAll('td')[0].element.textContent).to.equal('10:01'); + expect(rows[1].findAll('td')[1].element.textContent).to.equal('key4'); + expect(rows[1].findAll('td')[2].element.textContent).to.equal('key5'); + expect(rows[1].findAll('td')[3].element.textContent).to.equal('key6'); + expect(rows[1].findAll('td').length).to.equal(4); + expect(rows[2].findAll('td')[0].element.textContent).to.equal('10:02'); + expect(rows[2].findAll('td')[1].element.textContent).to.equal('value7'); + expect(rows[2].findAll('td')[2].element.textContent).to.equal('value8'); + expect(rows[2].findAll('td')[3].element.textContent).to.equal('value9'); + expect(rows[2].findAll('td').length).to.equal(4); + expect(rows.length).to.equal(3); +}); + +test('Should display message when inputs are empty', () => { + // Initialize component + const wrapper = shallowMount(DoubleOutputTable, { + propsData: { + calculateResult: () => ({ key: 'a', value: 'b', result: 'value', sort: 0 }), + targets: [ + { id: 0 }, + { id: 1 }, + { id: 2 }, + ], + inputTimes: [], + inputDistance: { + distanceUnit: 'miles', + distanceValue: 2, + }, + }, + }); + + // Assert headers are correctly generated + const headers = wrapper.findAll('th'); + expect(headers[0].element.textContent).to.equal('2 mi'); + expect(headers.length).to.equal(1); + + // Assert results are correctly rendered + const rows = wrapper.findAll('tbody tr'); + expect(rows[0].findAll('td')[0].text()).to.equal('No inputs were specified.'); + expect(rows[0].findAll('td').length).to.equal(1); + expect(rows.length).to.equal(1); +}); diff --git a/tests/unit/views/BatchCalculator.spec.js b/tests/unit/views/BatchCalculator.spec.js @@ -0,0 +1,279 @@ +import { beforeEach, test, expect } from 'vitest'; +import { shallowMount } from '@vue/test-utils'; +import BatchCalculator from '@/views/BatchCalculator.vue'; + +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, + })); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert options loaded + expect(wrapper.findComponent({ name: 'pace-input' }).vm.modelValue).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }); +}); + +test('should save input to localStorage when modified', async () => { + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Update input pace + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }); + + // Assert input saved + expect(localStorage.getItem('running-tools.batch-calculator-input')).to.equal(JSON.stringify({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + })); +}); + +test('should load batch options from localStorage', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.batch-calculator-options', JSON.stringify({ + calculator: 'race', + increment: 32, + rows: 15, + })); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert options loaded + expect(wrapper.find('select[aria-label="Calculator"]').element.value).to.equal('race'); + expect(wrapper.findComponent({ name: 'time-input' }).vm.modelValue).to.equal(32); + expect(wrapper.findComponent({ name: 'integer-input' }).vm.modelValue).to.equal(15); +}); + +test('should save batch options to localStorage when modified', async () => { + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // 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: 15, + 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: 'race', + increment: 32, + 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: 'race', + increment: 32, + rows: 15, + })); +}); + +test('should load selected target set from localStorage', async () => { + // Initialize localStorage + const selectedTargetSets = [ + { + name: 'Pace targets #1', + targets: [ + { type: 'distance', distanceValue: 1, distanceUnit: 'miles' }, + ], + }, + { + name: 'Race targets #1', + targets: [ + { type: 'distance', distanceValue: 3, distanceUnit: 'miles' }, + ], + }, + { + name: 'Workout targets #1', + targets: [ + { + type: 'distance', distanceValue: 5, distanceUnit: 'miles', + splitValue: 1, splitUnit: 'miles' + }, + ], + }, + ]; + localStorage.setItem('running-tools.pace-calculator-target-sets', JSON.stringify({ + 'A': selectedTargetSets[0], + 'B': { + name: 'Pace targets #2', + targets: [ + { type: 'distance', distanceValue: 2, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.pace-calculator-target-set', '"A"'); + localStorage.setItem('running-tools.race-calculator-target-sets', JSON.stringify({ + 'C': selectedTargetSets[1], + 'D': { + name: 'Race targets #2', + targets: [ + { type: 'distance', distanceValue: 4, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.race-calculator-target-set', '"C"'); + localStorage.setItem('running-tools.workout-calculator-target-sets', JSON.stringify({ + 'E': selectedTargetSets[2], + 'F': { + name: 'Workout targets #2', + targets: [ + { type: 'distance', distanceValue: 6, distanceUnit: 'miles' }, + ], + } + })); + localStorage.setItem('running-tools.workout-calculator-target-set', '"E"'); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert selected pace target set is loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[0].targets); + + // Assert selected race target set is loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('race'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[1].targets); + + // Assert selected workout target set is loaded + await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.targets) + .to.deep.equal(selectedTargetSets[2].targets); +}); + +test('should pass correct input props to DoubleOutputTable', async () => { + // Initialize component + const wrapper = shallowMount(BatchCalculator); + + // Assert that initial props are correct + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({ + distanceValue: 5, + distanceUnit: 'kilometers', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([ + 1200, 1215, 1230, 1245, 1260, 1275, 1290, 1305, 1320, 1335, + 1350, 1365, 1380, 1395, 1410, 1425, 1440, 1455, 1470, 1485, + ]); + + // Change input pace + await wrapper.findComponent({ name: 'pace-input' }).setValue({ + distanceValue: 2, + distanceUnit: 'miles', + time: 600, + }); + + // Assert that the props are updated + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([ + 600, 615, 630, 645, 660, 675, 690, 705, 720, 735, + 750, 765, 780, 795, 810, 825, 840, 855, 870, 885, + ]); + + // Change increment value + await wrapper.findComponent({ name: 'time-input' }).setValue(10); + + // Assert that the props are updated + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([ + 600, 610, 620, 630, 640, 650, 660, 670, 680, 690, + 700, 710, 720, 730, 740, 750, 760, 770, 780, 790, + ]); + + // Change number of rows + await wrapper.findComponent({ name: 'integer-input' }).setValue(15); + + // Assert that the props are updated + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputDistance).to.deep.equal({ + distanceValue: 2, + distanceUnit: 'miles', + }); + expect(wrapper.findComponent({ name: 'double-output-table' }).vm.inputTimes).to.deep.equal([ + 600, 610, 620, 630, 640, 650, 660, 670, 680, 690, 700, 710, 720, 730, 740, + ]); +}); + +test('should correctly calculate outputs', async () => { + // Initialize localStorage + localStorage.setItem('running-tools.race-calculator-options', JSON.stringify({ + model: 'PurdyPointsModel', + riegelExponent: 1.2, + })); + localStorage.setItem('running-tools.workout-calculator-options', JSON.stringify({ + model: 'RiegelModel', + riegelExponent: 1.1, + })); + localStorage.setItem('running-tools.default-unit-system', '"imperial"'); + + // Initialize component + const wrapper = shallowMount(BatchCalculator); + const input = { distanceValue: 2, distanceUnit: 'miles', time: 600 }; + + // Assert pace outputs are calculated correctly + await wrapper.find('select[aria-label="Calculator"]').setValue('pace'); + let calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult; + expect(calculate(input, { type: 'time', time: 3600 })).to.deep.equal({ + key: '12.00 mi', + value: '1:00:00', + pace: '5:00 / mi', + sort: 3600, + result: 'key', + }); + + // Assert race outputs are calculated correctly + await wrapper.find('select[aria-label="Calculator"]').setValue('race'); + calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult; + expect(calculate(input, { type: 'time', time: 3600 })).to.deep.equal({ + key: '10.93 mi', + value: '1:00:00', + pace: '5:29 / mi', + sort: 3600, + result: 'key', + }); + + // Assert workout outputs are calculated correctly + await wrapper.find('select[aria-label="Calculator"]').setValue('workout'); + calculate = wrapper.findComponent({ name: 'double-output-table' }).vm.calculateResult; + 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.07'); + expect(result.pace).to.equal(''); + expect(result.sort).to.be.closeTo(353.07, 0.01); + expect(result.result).to.equal('value'); +});