From 0efe6db5a86916bf585d08292d3b3eda2a8f3dcd Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 25 Dec 2025 18:45:32 -0700 Subject: [PATCH] Adding quart to ratio calculator. Adding tests. --- .../components/calculators/RatioCalculator.js | 208 +++++++++--------- .../calculators/RatioCalculator.test.js | 142 ++++++++++++ 2 files changed, 245 insertions(+), 105 deletions(-) create mode 100644 app/src/components/calculators/RatioCalculator.test.js diff --git a/app/src/components/calculators/RatioCalculator.js b/app/src/components/calculators/RatioCalculator.js index da9e97e3..1689b7a3 100644 --- a/app/src/components/calculators/RatioCalculator.js +++ b/app/src/components/calculators/RatioCalculator.js @@ -9,10 +9,8 @@ import {Media} from "../../contexts/contexts"; const nullUnit = createUnit('null'); function ratioReducer(prevState, e) { - const {value, name} = Object.keys(e).indexOf('target') >= 0 ? e.target : e; + const {value, name} = 'target' in e ? e.target : e; let {a, aUnit, b, bUnit, c, cUnit, d, dUnit, base, lastUpdated, recentUnits} = prevState; - console.log('ratioReducer', name, value, lastUpdated); - console.debug('ratioReducer', 'a:', a.toString(), 'b:', b.toString(), 'c:', c.toString(), 'd:', d.toString()); // Keep a stack of which inputs are updated. Limit it to the 3 most recent inputs. let newLastUpdated = lastUpdated || []; @@ -58,7 +56,6 @@ function ratioReducer(prevState, e) { base = value; // Get the unit of this base that the user used most recently. aUnit = bUnit = cUnit = dUnit = recentUnits[base] || nullUnit; - console.debug('ratioReducer', 'change base:', aUnit, 'value:', value); a = b = c = d = unit('', aUnit); // Reset last updated. newLastUpdated = []; @@ -111,15 +108,16 @@ const baseToUnitsMap = { ], 'volume': [ {key: 'cc', value: 'cc', text: 'cc'}, - {key: 'cuin', value: 'cuin', text: 'cuin'}, + {key: 'liter', value: 'liter', text: 'liter'}, + {key: 'm3', value: 'm3', text: 'meters³'}, {key: 'cup', value: 'cup', text: 'cup'}, {key: 'fluidounce', value: 'fluidounce', text: 'fl.oz'}, {key: 'gallon', value: 'gallon', text: 'gallon'}, - {key: 'liter', value: 'liter', text: 'liter'}, - {key: 'm3', value: 'm3', text: 'meters³'}, + {key: 'quart', value: 'quart', text: 'quart'}, {key: 'milliliter', value: 'milliliter', text: 'ml'}, {key: 'tablespoon', value: 'tablespoon', text: 'tablespoon'}, {key: 'teaspoon', value: 'teaspoon', text: 'teaspoon'}, + {key: 'cuin', value: 'cuin', text: 'cuin'}, ], 'mass': [ {key: 'grain', value: 'grain', text: 'grain'}, @@ -186,30 +184,53 @@ const baseOptions = [ {key: 'energy', value: 'energy', label: 'joule, watt, etc.', text: 'Energy'}, ]; +const RatioInput = React.forwardRef(({name, label, value, unitValue, unitName, unitOptions, color, dispatch, onKeyDown}, ref) => ( +
+
{label}
+ dispatch(e)} + onKeyDown={onKeyDown} + onFocus={e => e.target.select()} + /> + {unitOptions && + dispatch(data)} + name={unitName} + value={unitValue} + />} +
+)); + const RatioCalculator = () => { // Get the units the user recently used. These will be set if the user changes the base. const [storageRecentUnits, setStorageRecentUnits] = useLocalStorage('ratio_calculator_recent_units', initialState.recentUnits); - initialState.recentUnits = storageRecentUnits; - const [state, dispatch] = React.useReducer(ratioReducer, initialState); + const [state, dispatch] = React.useReducer( + ratioReducer, + storageRecentUnits, + (recentUnits) => ({...initialState, recentUnits}) + ); // Overwrite the most recently used unit for each base. React.useEffect(() => { - const key = unitsToBaseMap[state.aUnit]; - setStorageRecentUnits({...storageRecentUnits, [key]: state.aUnit}); - }, [state.aUnit]); - React.useEffect(() => { - const key = unitsToBaseMap[state.bUnit]; - setStorageRecentUnits({...storageRecentUnits, [key]: state.bUnit}); - }, [state.bUnit]); - React.useEffect(() => { - const key = unitsToBaseMap[state.cUnit]; - setStorageRecentUnits({...storageRecentUnits, [key]: state.cUnit}); - }, [state.cUnit]); - React.useEffect(() => { - const key = unitsToBaseMap[state.dUnit]; - setStorageRecentUnits({...storageRecentUnits, [key]: state.dUnit}); - }, [state.dUnit]); + const updates = {}; + [state.aUnit, state.bUnit, state.cUnit, state.dUnit].forEach(unitValue => { + const baseKey = unitsToBaseMap[unitValue]; + if (baseKey) { + updates[baseKey] = unitValue; + } + }); + if (Object.keys(updates).length > 0) { + setStorageRecentUnits(prev => ({...prev, ...updates})); + } + }, [state.aUnit, state.bUnit, state.cUnit, state.dUnit, setStorageRecentUnits]); const inputARef = React.useRef(null); const inputBRef = React.useRef(null); @@ -218,16 +239,7 @@ const RatioCalculator = () => { const unitOptions = baseToUnitsMap[state.base]; - const unitDropdownOptions = { - options: unitOptions, - className: 'label', - onChange: (e, data) => dispatch(data), - }; - - const aColor = state.lastUpdated.indexOf('a') >= 0 ? '' : 'grey'; - const bColor = state.lastUpdated.indexOf('b') >= 0 ? '' : 'grey'; - const cColor = state.lastUpdated.indexOf('c') >= 0 ? '' : 'grey'; - const dColor = state.lastUpdated.indexOf('d') >= 0 ? '' : 'grey'; + const getColor = (name) => state.lastUpdated.includes(name) ? '' : 'grey'; const baseDropdown = { options={baseOptions} value={state.base} name='base' - onChange={(e, data) => dispatch(data)} + onChange={(_, data) => dispatch(data)} style={{marginBottom: '1em'}} />; const handleInputChange = (e) => { // Allow the user to switch between inputs using a, b, c, and d keys. - if (e?.key === 'a') { - inputARef.current.focus(); - } else if (e?.key === 'b') { - inputBRef.current.focus(); - } else if (e?.key === 'c') { - inputCRef.current.focus(); - } else if (e?.key === 'd') { - inputDRef.current.focus(); + const refs = {a: inputARef, b: inputBRef, c: inputCRef, d: inputDRef}; + if (refs[e?.key]) { + refs[e.key].current.focus(); } - } + }; - const inputA =
-
A
- dispatch(e)} - onKeyPress={handleInputChange} - onFocus={e => e.target.select()} - /> - {state.base && - } -
; + const inputA = ; - const inputB =
-
B
- dispatch(e)} - onKeyPress={handleInputChange} - onFocus={e => e.target.select()} - /> - {state.base && - } -
; + const inputB = ; - const inputC =
-
C
- dispatch(e)} - onKeyPress={handleInputChange} - onFocus={e => e.target.select()} - /> - {state.base && - } -
; + const inputC = ; - const inputD =
-
D
- dispatch(e)} - onKeyPress={handleInputChange} - onFocus={e => e.target.select()} - /> - {state.base && - } -
; + const inputD = ; React.useEffect(() => { if (inputARef.current) { @@ -375,4 +370,7 @@ const RatioCalculator = () => { export const RatioCalculators = () => { return -} \ No newline at end of file +} + +// Exported for testing +export { ratioReducer }; diff --git a/app/src/components/calculators/RatioCalculator.test.js b/app/src/components/calculators/RatioCalculator.test.js new file mode 100644 index 00000000..24992d5f --- /dev/null +++ b/app/src/components/calculators/RatioCalculator.test.js @@ -0,0 +1,142 @@ +import {ratioReducer} from './RatioCalculator'; +import {unit} from 'mathjs'; + +// The nullUnit is already created by RatioCalculator.js import, so we can reference it by name +const nullUnit = 'null'; + +describe('ratioReducer', () => { + const createInitialState = () => ({ + base: null, + lastUpdated: [], + a: unit('', nullUnit), + b: unit('', nullUnit), + c: unit('', nullUnit), + d: unit('', nullUnit), + aUnit: nullUnit, + bUnit: nullUnit, + cUnit: nullUnit, + dUnit: nullUnit, + recentUnits: {}, + }); + + describe('ratio calculations (A:B = C:D)', () => { + test('calculates A = B*C/D when B, C, D are set', () => { + let state = createInitialState(); + // Set B=2, C=6, D=3 → A should be 4 (2*6/3 = 4) + state = ratioReducer(state, {name: 'b', value: '2'}); + state = ratioReducer(state, {name: 'c', value: '6'}); + state = ratioReducer(state, {name: 'd', value: '3'}); + + expect(state.a.toNumber()).toBeCloseTo(4, 5); + }); + + test('calculates B = A*D/C when A, C, D are set', () => { + let state = createInitialState(); + // Set A=4, C=6, D=3 → B should be 2 (4*3/6 = 2) + state = ratioReducer(state, {name: 'a', value: '4'}); + state = ratioReducer(state, {name: 'c', value: '6'}); + state = ratioReducer(state, {name: 'd', value: '3'}); + + expect(state.b.toNumber()).toBeCloseTo(2, 5); + }); + + test('calculates C = A*D/B when A, B, D are set', () => { + let state = createInitialState(); + // Set A=4, B=2, D=3 → C should be 6 (4*3/2 = 6) + state = ratioReducer(state, {name: 'a', value: '4'}); + state = ratioReducer(state, {name: 'b', value: '2'}); + state = ratioReducer(state, {name: 'd', value: '3'}); + + expect(state.c.toNumber()).toBeCloseTo(6, 5); + }); + + test('calculates D = B*C/A when A, B, C are set', () => { + let state = createInitialState(); + // Set A=4, B=2, C=6 → D should be 3 (2*6/4 = 3) + state = ratioReducer(state, {name: 'a', value: '4'}); + state = ratioReducer(state, {name: 'b', value: '2'}); + state = ratioReducer(state, {name: 'c', value: '6'}); + + expect(state.d.toNumber()).toBeCloseTo(3, 5); + }); + + test('does not calculate when only 2 values are set', () => { + let state = createInitialState(); + state = ratioReducer(state, {name: 'a', value: '4'}); + state = ratioReducer(state, {name: 'b', value: '2'}); + + // C and D should remain at 0 (empty) + expect(state.c.toNumber()).toBe(0); + expect(state.d.toNumber()).toBe(0); + }); + + test('recalculates when a value changes', () => { + let state = createInitialState(); + // Initial: A=4, B=2, C=6 → D=3 + state = ratioReducer(state, {name: 'a', value: '4'}); + state = ratioReducer(state, {name: 'b', value: '2'}); + state = ratioReducer(state, {name: 'c', value: '6'}); + expect(state.d.toNumber()).toBeCloseTo(3, 5); + + // Change A to 8 → D should recalculate to 1.5 (2*6/8 = 1.5) + state = ratioReducer(state, {name: 'a', value: '8'}); + expect(state.d.toNumber()).toBeCloseTo(1.5, 5); + }); + }); + + describe('unit conversions', () => { + const createLengthState = () => ({ + base: 'length', + lastUpdated: [], + a: unit('', 'meter'), + b: unit('', 'meter'), + c: unit('', 'meter'), + d: unit('', 'meter'), + aUnit: 'meter', + bUnit: 'meter', + cUnit: 'meter', + dUnit: 'meter', + recentUnits: {length: 'meter'}, + }); + + test('converts value when unit changes', () => { + let state = createLengthState(); + // Set A = 1 meter + state = ratioReducer(state, {name: 'a', value: '1'}); + expect(state.a.toNumber()).toBeCloseTo(1, 5); + + // Change unit to feet (1 meter ≈ 3.28084 feet) + state = ratioReducer(state, {name: 'aUnit', value: 'feet'}); + expect(state.a.toNumber()).toBeCloseTo(3.28084, 3); + }); + + test('calculates ratio correctly with length units', () => { + let state = createLengthState(); + // Set B=2m, C=3m, D=1m → A should be 6m + state = ratioReducer(state, {name: 'b', value: '2'}); + state = ratioReducer(state, {name: 'c', value: '3'}); + state = ratioReducer(state, {name: 'd', value: '1'}); + + expect(state.a.toNumber()).toBeCloseTo(6, 5); + }); + + test('preserves ratio after unit conversion', () => { + let state = createLengthState(); + // Set up ratio: A=6, B=2, C=3, D=1 (6:2 = 3:1) + state = ratioReducer(state, {name: 'b', value: '2'}); + state = ratioReducer(state, {name: 'c', value: '3'}); + state = ratioReducer(state, {name: 'd', value: '1'}); + expect(state.a.toNumber()).toBeCloseTo(6, 5); + + // Convert A to feet - the numeric value changes but ratio is preserved + state = ratioReducer(state, {name: 'aUnit', value: 'feet'}); + // A is now in feet, but the ratio A:B should still equal C:D + // A (in feet) / B (in meters) should equal C/D when converted to same units + const ratioAB = state.a.toNumber() / state.b.toNumber(); + const ratioCD = state.c.toNumber() / state.d.toNumber(); + // Note: ratioAB won't equal ratioCD directly because units differ + // But A converted back to meters / B should equal C/D + expect(state.a.to('meter').toNumber() / state.b.toNumber()).toBeCloseTo(ratioCD, 5); + }); + }); +});