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:
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"/>
+ ×
+ <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');
+});