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);
+ });
+ });
+});