Adding quart to ratio calculator. Adding tests.
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Controller Tests / controller-tests (push) Waiting to run
Jest Tests / jest-tests (push) Waiting to run

This commit is contained in:
Roland 2025-12-25 18:45:32 -07:00
parent c55c8e24c9
commit 0efe6db5a8
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
2 changed files with 245 additions and 105 deletions

View file

@ -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) => (
<div className="ui fluid labeled input" style={{marginBottom: '0.25em'}}>
<div className={`ui label ${color}`}>{label}</div>
<input
id={name}
name={name}
type="number"
ref={ref}
value={unitToInputValue(value)}
onChange={e => dispatch(e)}
onKeyDown={onKeyDown}
onFocus={e => e.target.select()}
/>
{unitOptions &&
<Dropdown
options={unitOptions}
className='label'
onChange={(_, data) => dispatch(data)}
name={unitName}
value={unitValue}
/>}
</div>
));
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 = <Dropdown selection
fluid
@ -235,86 +247,69 @@ const RatioCalculator = () => {
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 = <div className="ui fluid labeled input" style={{marginBottom: '0.25em'}}>
<div className={`ui label ${aColor}`}>A</div>
<input
id="a"
name="a"
type="number"
ref={inputARef}
value={unitToInputValue(state.a)}
onChange={e => dispatch(e)}
onKeyPress={handleInputChange}
onFocus={e => e.target.select()}
/>
{state.base &&
<Dropdown {...unitDropdownOptions} name='aUnit' value={state.aUnit}/>}
</div>;
const inputA = <RatioInput
ref={inputARef}
name="a"
label="A"
value={state.a}
unitValue={state.aUnit}
unitName="aUnit"
unitOptions={unitOptions}
color={getColor('a')}
dispatch={dispatch}
onKeyDown={handleInputChange}
/>;
const inputB = <div className="ui fluid labeled input" style={{marginBottom: '0.25em'}}>
<div className={`ui label ${bColor}`}>B</div>
<input
id="b"
name="b"
type="number"
ref={inputBRef}
value={unitToInputValue(state.b)}
onChange={e => dispatch(e)}
onKeyPress={handleInputChange}
onFocus={e => e.target.select()}
/>
{state.base &&
<Dropdown {...unitDropdownOptions} name='bUnit' value={state.bUnit}/>}
</div>;
const inputB = <RatioInput
ref={inputBRef}
name="b"
label="B"
value={state.b}
unitValue={state.bUnit}
unitName="bUnit"
unitOptions={unitOptions}
color={getColor('b')}
dispatch={dispatch}
onKeyDown={handleInputChange}
/>;
const inputC = <div className="ui fluid labeled input" style={{marginBottom: '0.25em'}}>
<div className={`ui label ${cColor}`}>C</div>
<input
id="c"
name="c"
type="number"
ref={inputCRef}
value={unitToInputValue(state.c)}
onChange={e => dispatch(e)}
onKeyPress={handleInputChange}
onFocus={e => e.target.select()}
/>
{state.base &&
<Dropdown {...unitDropdownOptions} name='cUnit' value={state.cUnit}/>}
</div>;
const inputC = <RatioInput
ref={inputCRef}
name="c"
label="C"
value={state.c}
unitValue={state.cUnit}
unitName="cUnit"
unitOptions={unitOptions}
color={getColor('c')}
dispatch={dispatch}
onKeyDown={handleInputChange}
/>;
const inputD = <div className="ui fluid labeled input" style={{marginBottom: '0.25em'}}>
<div className={`ui label ${dColor}`}>D</div>
<input
id="d"
name="d"
type="number"
ref={inputDRef}
value={unitToInputValue(state.d)}
onChange={e => dispatch(e)}
onKeyPress={handleInputChange}
onFocus={e => e.target.select()}
/>
{state.base &&
<Dropdown {...unitDropdownOptions} name='dUnit' value={state.dUnit}/>}
</div>;
const inputD = <RatioInput
ref={inputDRef}
name="d"
label="D"
value={state.d}
unitValue={state.dUnit}
unitName="dUnit"
unitOptions={unitOptions}
color={getColor('d')}
dispatch={dispatch}
onKeyDown={handleInputChange}
/>;
React.useEffect(() => {
if (inputARef.current) {
@ -375,4 +370,7 @@ const RatioCalculator = () => {
export const RatioCalculators = () => {
return <RatioCalculator/>
}
}
// Exported for testing
export { ratioReducer };

View file

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