mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Add Water Storage calculator (people → duration → containers)
New offline prepper calculator under Calculators: - Men/women/children/pregnant-nursing counts with editable per-person daily rates (drinking + hygiene/dish use). - Minimum/Comfortable presets grounded in CDC/FEMA (1 gal/person/day floor, store 2 weeks) and National Academies adequate-intake figures; Pregnant/Nursing adds the CDC/Ready.gov +0.5/+1 gal. Sources cited in the Daily Use info popup. - Duration slider on a clean ladder (days → weeks → months → years) so it never lands on awkward values like "68 days". - Containers Needed table listing every container (IBC tote → 500 mL bottle) with capacity and count + spare for the total volume. - Total water and total weight (8.345 lb/gal, 1 kg/L). - Imperial/metric, preset, and people counts persist via local storage. Pure functions (dailyDemand, totalWater, containersNeeded, waterWeight, formatDuration) covered by 21 unit tests. Registered in the calculator list and global app search. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2e950b85ad
commit
c63fece967
4 changed files with 474 additions and 0 deletions
|
|
@ -9,6 +9,7 @@ import {Link} from "react-router";
|
|||
import {RatioCalculators} from "./calculators/RatioCalculator";
|
||||
import {QRCodeCalculator} from "./calculators/QRCodeCalculator";
|
||||
import {DriveCalculator} from "./calculators/DriveCalculator";
|
||||
import {WaterCalculator} from "./calculators/WaterCalculator";
|
||||
|
||||
export const useCalculators = () => {
|
||||
const [calc, setCalc] = useCalcQuery();
|
||||
|
|
@ -17,6 +18,7 @@ export const useCalculators = () => {
|
|||
{key: 'ratio', icon: 'th large', button: 'Ratio', contents: <RatioCalculators/>},
|
||||
{key: 'electrical', icon: 'lightning', button: 'Electrical', contents: <ElectricalCalculators/>},
|
||||
{key: 'drive', icon: 'cogs', button: 'Drive Ratio', contents: <DriveCalculator/>},
|
||||
{key: 'water', icon: 'tint', button: 'Water Storage', contents: <WaterCalculator/>},
|
||||
{key: 'antenna', icon: 'signal', button: 'Antenna', contents: <DipoleAntennaCalculator/>},
|
||||
{key: 'temperature', icon: 'thermometer', button: 'Temperature', contents: <TemperatureCalculator/>},
|
||||
{key: 'qrCode', icon: 'qrcode', button: 'QR Code', contents: <QRCodeCalculator/>},
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ const SUGGESTED_APPS = [
|
|||
title: 'Drive Ratio Calculator',
|
||||
description: 'Calculate pulley, gear, and sprocket ratios, RPM, torque, belt and chain length.'
|
||||
},
|
||||
{
|
||||
location: '/more/calculators?calc=water',
|
||||
title: 'Water Storage Calculator',
|
||||
description: 'Calculate water storage for your household: drinking and hygiene needs, how much to store, and how many drums, buckets, or bottles you need.'
|
||||
},
|
||||
];
|
||||
|
||||
export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false, model) => {
|
||||
|
|
|
|||
310
app/src/components/calculators/WaterCalculator.js
Normal file
310
app/src/components/calculators/WaterCalculator.js
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import React from "react";
|
||||
import {GridColumn, Input, Label, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
|
||||
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
|
||||
import {Form, Header, Segment, Table} from "../Theme";
|
||||
import {InfoPopup, roundDigits, Toggle, useLocalStorage} from "../Common";
|
||||
import {Media} from "../../contexts/contexts";
|
||||
|
||||
// Pure calculation functions for the Water Storage calculator.
|
||||
//
|
||||
// The model is deliberately simple: a household of some men, women, and children each
|
||||
// consume a per-day amount of water (drinking plus hygiene/dish use). Over a chosen
|
||||
// duration that becomes a total volume, which the user stores in some number of a chosen
|
||||
// container (55-gallon drums, 5-gallon buckets, 2-liter bottles, ...).
|
||||
//
|
||||
// Functions are unit-agnostic: pass consistent units (all gallons, or all liters) and the
|
||||
// result comes back in those same units. The React component picks imperial vs metric and
|
||||
// supplies the matching per-category rates and container capacities.
|
||||
|
||||
// Divide `a` by `b`, returning null when the result would not be a finite number.
|
||||
const safeDiv = (a, b) => {
|
||||
const result = a / b;
|
||||
return Number.isFinite(result) ? result : null;
|
||||
};
|
||||
|
||||
// Total water the household needs per day: sum of (count * rate) for each category.
|
||||
// Missing/blank/non-positive counts or rates contribute nothing rather than NaN.
|
||||
export function dailyDemand(counts, rates) {
|
||||
const term = (count, rate) => {
|
||||
const c = Number(count), r = Number(rate);
|
||||
return (c > 0 && r > 0) ? c * r : 0;
|
||||
};
|
||||
return term(counts.men, rates.men)
|
||||
+ term(counts.women, rates.women)
|
||||
+ term(counts.children, rates.children)
|
||||
+ term(counts.pregnant, rates.pregnant);
|
||||
}
|
||||
|
||||
// Total volume to store for `days` days at the given daily demand.
|
||||
export function totalWater(daily, days) {
|
||||
if (!(daily > 0) || !(days > 0)) {
|
||||
return null;
|
||||
}
|
||||
return daily * days;
|
||||
}
|
||||
|
||||
// Number of containers of `capacity` needed to hold `totalVolume`, rounded up because a
|
||||
// partial container still has to be a whole container. `leftover` is the unused capacity
|
||||
// in the last container. Returns null for impossible inputs.
|
||||
export function containersNeeded(totalVolume, capacity) {
|
||||
if (!(totalVolume > 0) || !(capacity > 0)) {
|
||||
return null;
|
||||
}
|
||||
const count = Math.ceil(totalVolume / capacity);
|
||||
return {count, leftover: count * capacity - totalVolume};
|
||||
}
|
||||
|
||||
// Approximate weight of a volume of water: ~8.345 lb per US gallon, or 1 kg per liter.
|
||||
// Useful for shelving/vehicle planning. Returns null when the volume is not positive.
|
||||
export function waterWeight(volume, metric) {
|
||||
if (!(volume > 0)) {
|
||||
return null;
|
||||
}
|
||||
return metric ? volume * 1 : volume * 8.345;
|
||||
}
|
||||
|
||||
// Human-friendly label for a duration in days: days up to a week, then weeks, then months,
|
||||
// then whole years. Keeps the slider from showing awkward values like "68 days".
|
||||
export function formatDuration(days) {
|
||||
const d = Math.round(Number(days));
|
||||
if (!(d > 0)) {
|
||||
return '—';
|
||||
}
|
||||
if (d <= 7) {
|
||||
return `${d} day${d === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (d < 30) {
|
||||
const weeks = Math.round(d / 7);
|
||||
return `${weeks} week${weeks === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (d < 360) {
|
||||
const months = Math.round(d / 30);
|
||||
return `${months} month${months === 1 ? '' : 's'}`;
|
||||
}
|
||||
const years = d / 360;
|
||||
if (Number.isInteger(years)) {
|
||||
return `${years} year${years === 1 ? '' : 's'}`;
|
||||
}
|
||||
const months = Math.round(d / 30);
|
||||
return `${months} months`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Per-category daily water need, including drinking plus hygiene and dish use. These are
|
||||
// editable starting points. "Minimum" sits at the CDC/FEMA emergency floor of ~1 gallon
|
||||
// per person per day (half drinking, half hygiene/cooking); men/women/children are scaled
|
||||
// by the drinking portion using the National Academies adequate-intake figures (men ~3.7 L,
|
||||
// women ~2.7 L of total water/day), while the hygiene/cooking half is shared. Pregnant and
|
||||
// nursing women get the extra CDC/Ready.gov calls out (~+0.5 gal pregnant, ~+1 gal nursing;
|
||||
// NAS lactating ~3.8 L). "Comfortable" roughly doubles the floor to allow real washing,
|
||||
// cooking, and dishes. Metric values are the imperial values converted to liters and
|
||||
// rounded; the component swaps tables on toggle.
|
||||
export const WATER_PRESETS = {
|
||||
minimum: {
|
||||
label: 'Minimum',
|
||||
description: 'CDC emergency floor (~1 gal/person/day)',
|
||||
imperial: {men: 1.0, women: 0.9, children: 0.8, pregnant: 1.6}, // gallons/day
|
||||
metric: {men: 3.8, women: 3.4, children: 3.0, pregnant: 6.1}, // liters/day
|
||||
},
|
||||
comfortable: {
|
||||
label: 'Comfortable',
|
||||
description: 'Adds real washing, cooking, and dishes',
|
||||
imperial: {men: 2.0, women: 1.8, children: 1.5, pregnant: 2.8},
|
||||
metric: {men: 7.6, women: 6.8, children: 5.7, pregnant: 10.6},
|
||||
},
|
||||
};
|
||||
|
||||
// Common water-storage containers, capacity in both gallons and liters. Ordered large to
|
||||
// small so the dropdown reads from bulk storage down to single bottles.
|
||||
export const CONTAINERS = [
|
||||
{key: 'ibc', label: 'IBC tote', gallons: 275, liters: 1041},
|
||||
{key: 'drum55', label: '55-gallon drum', gallons: 55, liters: 208},
|
||||
{key: 'drum30', label: '30-gallon drum', gallons: 30, liters: 114},
|
||||
{key: 'jug7', label: '7-gallon jug', gallons: 7, liters: 26.5},
|
||||
{key: 'bucket5', label: '5-gallon bucket', gallons: 5, liters: 18.9},
|
||||
{key: 'waterbrick', label: 'WaterBrick', gallons: 3.5, liters: 13.2},
|
||||
{key: 'jug1', label: '1-gallon jug', gallons: 1, liters: 3.785},
|
||||
{key: 'bottle2l', label: '2-liter soda bottle', gallons: 0.528, liters: 2},
|
||||
{key: 'bottle1l', label: '1-liter bottle', gallons: 0.264, liters: 1},
|
||||
{key: 'bottle500', label: '500 mL water bottle', gallons: 0.132, liters: 0.5},
|
||||
];
|
||||
|
||||
// Slider stops in days: daily for the first week, then weekly, then monthly (grouped by 30
|
||||
// per the "no awkward 68-day values" rule), out to a couple of years.
|
||||
export const DURATION_STOPS = [
|
||||
1, 2, 3, 4, 5, 6, 7,
|
||||
14, 21,
|
||||
30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330, 360,
|
||||
540, 720,
|
||||
];
|
||||
|
||||
// Explanation shown in the "Daily Use" info popup. Cites the figures the presets are
|
||||
// built from so the numbers are traceable offline.
|
||||
const DEMAND_INFO = 'Per-person daily water includes drinking plus basic hygiene and dish '
|
||||
+ 'washing — not just drinking. The Minimum preset follows the CDC/FEMA emergency floor '
|
||||
+ 'of about 1 gallon (3.8 L) per person per day (roughly half for drinking, half for '
|
||||
+ 'hygiene and cooking), and both agencies recommend storing a 2-week supply. The '
|
||||
+ 'men/women/children split scales the drinking portion using the U.S. National Academies '
|
||||
+ 'adequate-intake figures (men ~3.7 L, women ~2.7 L of total water per day). The '
|
||||
+ 'Pregnant/Nursing category adds the extra CDC/Ready.gov calls out — about +0.5 gallon '
|
||||
+ 'for pregnant and +1 gallon for nursing women. CDC also advises storing more for sick '
|
||||
+ 'people, pets, and hot climates — edit the rates to match your household.';
|
||||
|
||||
// Per-category colors (Paul Tol bright palette) so the count inputs read at a glance.
|
||||
const CATEGORY_META = [
|
||||
{key: 'men', label: 'Men', icon: 'man', color: '#4477aa'},
|
||||
{key: 'women', label: 'Women', icon: 'woman', color: '#aa3377'},
|
||||
{key: 'children', label: 'Children', icon: 'child', color: '#228833'},
|
||||
{key: 'pregnant', label: 'Pregnant/Nursing', icon: 'heart', color: '#ee6677'},
|
||||
];
|
||||
|
||||
// Pick black or white label text for legibility on a given background color.
|
||||
function textColorFor(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.6 ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
// Format a positive number with a unit, or an em-dash when unavailable.
|
||||
function fmt(value, unit) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '—';
|
||||
}
|
||||
return unit ? `${roundDigits(value, 2)} ${unit}` : `${roundDigits(value, 2)}`;
|
||||
}
|
||||
|
||||
export function WaterCalculator() {
|
||||
const [metric, setMetric] = useLocalStorage('water_calculator_metric', false);
|
||||
const [preset, setPreset] = useLocalStorage('water_calculator_preset', 'minimum');
|
||||
const [counts, setCounts] = useLocalStorage('water_calculator_counts',
|
||||
{men: '1', women: '1', children: '', pregnant: ''});
|
||||
const [rates, setRates] = React.useState({...WATER_PRESETS.minimum.imperial});
|
||||
const [durationIndex, setDurationIndex] = React.useState(DURATION_STOPS.indexOf(14));
|
||||
|
||||
// Reset the editable rates to the preset's defaults whenever the preset or unit changes.
|
||||
React.useEffect(() => {
|
||||
const p = WATER_PRESETS[preset];
|
||||
setRates(metric ? {...p.metric} : {...p.imperial});
|
||||
}, [preset, metric]);
|
||||
|
||||
const volumeUnit = metric ? 'L' : 'gal';
|
||||
const rateUnit = metric ? 'L/day' : 'gal/day';
|
||||
const weightUnit = metric ? 'kg' : 'lb';
|
||||
const days = DURATION_STOPS[durationIndex];
|
||||
|
||||
const daily = dailyDemand(counts, rates);
|
||||
const total = totalWater(daily, days);
|
||||
const weight = waterWeight(total, metric);
|
||||
|
||||
const inputProps = {fluid: true, type: 'number', onSelect: e => e.target.select(), autoComplete: 'off'};
|
||||
|
||||
// One count input per category, label colored to match.
|
||||
const countInput = ({key, label, icon, color}) =>
|
||||
<Input {...inputProps} min={0} step={1} name={`count-${key}`}
|
||||
labelPosition='left' value={counts[key]}
|
||||
onChange={e => setCounts({...counts, [key]: e.target.value === '' ? '' : String(Math.max(0, Math.round(Number(e.target.value))))})}
|
||||
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
|
||||
<i className={`${icon} icon`} style={{marginRight: '0.4em'}}/>{label}</Label>}/>;
|
||||
|
||||
// One editable rate input per category, sharing the category color.
|
||||
const rateInput = ({key, label, color}) =>
|
||||
<Input {...inputProps} min={0} step={0.1} name={`rate-${key}`}
|
||||
labelPosition='left' value={rates[key]}
|
||||
onChange={e => setRates({...rates, [key]: e.target.value})}
|
||||
label={<Label style={{backgroundColor: color, color: textColorFor(color), borderColor: color}}>
|
||||
{label}</Label>}/>;
|
||||
|
||||
const presetButtons = <Toggle label={`${WATER_PRESETS[preset].label} — ${WATER_PRESETS[preset].description}`}
|
||||
checked={preset === 'comfortable'}
|
||||
onChange={() => setPreset(preset === 'comfortable' ? 'minimum' : 'comfortable')}/>;
|
||||
|
||||
// One row per container: how many of it the total water would fill, and the spare
|
||||
// capacity in the last one. Shown for every container so the user can compare.
|
||||
const containerRows = CONTAINERS.map(c => {
|
||||
const capacity = metric ? c.liters : c.gallons;
|
||||
const need = total !== null ? containersNeeded(total, capacity) : null;
|
||||
return <TableRow key={c.key}>
|
||||
<TableCell>{c.label}</TableCell>
|
||||
<TableCell>{capacity} {volumeUnit}</TableCell>
|
||||
<TableCell>
|
||||
{need === null
|
||||
? '—'
|
||||
: <span><b>{need.count}</b>
|
||||
{need.leftover > 0 &&
|
||||
<span style={{opacity: 0.7}}> ({fmt(need.leftover, volumeUnit)} spare)</span>}
|
||||
</span>}
|
||||
</TableCell>
|
||||
</TableRow>;
|
||||
});
|
||||
|
||||
return <Form>
|
||||
<Header as='h1'>Water Storage</Header>
|
||||
|
||||
<div style={{marginBottom: '1em'}}>
|
||||
<Toggle label={metric ? 'Metric (liters)' : 'Imperial (gallons)'}
|
||||
checked={metric} onChange={() => setMetric(!metric)}/>
|
||||
</div>
|
||||
<div style={{marginBottom: '1em'}}>{presetButtons}</div>
|
||||
|
||||
<Header as='h3'>People</Header>
|
||||
<Grid stackable columns={2}>
|
||||
{CATEGORY_META.map(meta =>
|
||||
<GridColumn key={meta.key}>{countInput(meta)}</GridColumn>)}
|
||||
</Grid>
|
||||
|
||||
<Header as='h3' style={{marginTop: '1em'}}>
|
||||
Daily Use ({rateUnit} per person) <InfoPopup content={DEMAND_INFO}/>
|
||||
</Header>
|
||||
<Grid stackable columns={2}>
|
||||
{CATEGORY_META.map(meta =>
|
||||
<GridColumn key={meta.key}>{rateInput(meta)}</GridColumn>)}
|
||||
</Grid>
|
||||
|
||||
<Header as='h3' style={{marginTop: '1em'}}>How long?</Header>
|
||||
<div style={{padding: '0 0.5em'}}>
|
||||
<input type='range' min={0} max={DURATION_STOPS.length - 1} step={1}
|
||||
value={durationIndex} aria-label='Duration'
|
||||
onChange={e => setDurationIndex(Number(e.target.value))}
|
||||
style={{width: '100%'}}/>
|
||||
<Header as='h2' textAlign='center' style={{marginTop: '0.25em'}}>
|
||||
{formatDuration(days)}
|
||||
</Header>
|
||||
</div>
|
||||
|
||||
<Segment style={{marginTop: '1em'}}>
|
||||
<Table definition unstackable>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell width={6}>Daily Use</TableCell>
|
||||
<TableCell>{fmt(daily, `${volumeUnit}/day`)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Total Water for {formatDuration(days)}</TableCell>
|
||||
<TableCell>{fmt(total, volumeUnit)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>Total Weight</TableCell>
|
||||
<TableCell>{fmt(weight, weightUnit)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Header as='h3'>Containers Needed</Header>
|
||||
<Table unstackable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Container</TableHeaderCell>
|
||||
<TableHeaderCell>Capacity</TableHeaderCell>
|
||||
<TableHeaderCell>Needed</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{containerRows}</TableBody>
|
||||
</Table>
|
||||
</Segment>
|
||||
</Form>;
|
||||
}
|
||||
157
app/src/components/calculators/WaterCalculator.test.js
Normal file
157
app/src/components/calculators/WaterCalculator.test.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import {
|
||||
CONTAINERS,
|
||||
containersNeeded,
|
||||
dailyDemand,
|
||||
DURATION_STOPS,
|
||||
formatDuration,
|
||||
totalWater,
|
||||
WATER_PRESETS,
|
||||
waterWeight,
|
||||
} from "./WaterCalculator";
|
||||
|
||||
describe('dailyDemand', () => {
|
||||
test('sums each category count times its rate', () => {
|
||||
// 2 men * 1 + 1 woman * 0.9 + 3 children * 0.5 = 2 + 0.9 + 1.5 = 4.4
|
||||
const demand = dailyDemand({men: 2, women: 1, children: 3}, {men: 1, women: 0.9, children: 0.5});
|
||||
expect(demand).toBeCloseTo(4.4, 5);
|
||||
});
|
||||
|
||||
test('includes the pregnant/nursing category', () => {
|
||||
// 1 man * 1 + 1 pregnant * 1.6 = 2.6
|
||||
const demand = dailyDemand({men: 1, women: 0, children: 0, pregnant: 1},
|
||||
{men: 1, women: 0.9, children: 0.8, pregnant: 1.6});
|
||||
expect(demand).toBeCloseTo(2.6, 5);
|
||||
});
|
||||
|
||||
test('ignores blank, zero, and negative counts/rates', () => {
|
||||
expect(dailyDemand({men: '', women: 0, children: -2}, {men: 1, women: 1, children: 1})).toBe(0);
|
||||
// A category with a count but no rate contributes nothing.
|
||||
expect(dailyDemand({men: 5, women: 1, children: 0}, {men: '', women: 2, children: 5})).toBe(2);
|
||||
});
|
||||
|
||||
test('handles string inputs from form fields', () => {
|
||||
expect(dailyDemand({men: '1', women: '1', children: ''}, {men: '1.0', women: '0.9', children: '0.6'}))
|
||||
.toBeCloseTo(1.9, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('totalWater', () => {
|
||||
test('multiplies daily demand by the number of days', () => {
|
||||
expect(totalWater(4, 14)).toBe(56);
|
||||
});
|
||||
|
||||
test('returns null when demand or days is not positive', () => {
|
||||
expect(totalWater(0, 14)).toBeNull();
|
||||
expect(totalWater(4, 0)).toBeNull();
|
||||
expect(totalWater(NaN, 14)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('containersNeeded', () => {
|
||||
test('rounds up to a whole number of containers and reports the spare capacity', () => {
|
||||
// 56 gallons into 5-gallon buckets -> 12 buckets (60 gal), 4 gal spare.
|
||||
expect(containersNeeded(56, 5)).toEqual({count: 12, leftover: 4});
|
||||
});
|
||||
|
||||
test('an exact fit has no leftover', () => {
|
||||
expect(containersNeeded(110, 55)).toEqual({count: 2, leftover: 0});
|
||||
});
|
||||
|
||||
test('any positive volume needs at least one container', () => {
|
||||
expect(containersNeeded(0.1, 55)).toEqual({count: 1, leftover: 54.9});
|
||||
});
|
||||
|
||||
test('returns null for impossible inputs', () => {
|
||||
expect(containersNeeded(0, 5)).toBeNull();
|
||||
expect(containersNeeded(10, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('waterWeight', () => {
|
||||
test('imperial uses ~8.345 lb per gallon', () => {
|
||||
expect(waterWeight(10, false)).toBeCloseTo(83.45, 2);
|
||||
});
|
||||
|
||||
test('metric uses 1 kg per liter', () => {
|
||||
expect(waterWeight(10, true)).toBe(10);
|
||||
});
|
||||
|
||||
test('returns null for non-positive volume', () => {
|
||||
expect(waterWeight(0, false)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDuration', () => {
|
||||
test('labels by the appropriate unit and groups cleanly', () => {
|
||||
expect(formatDuration(1)).toBe('1 day');
|
||||
expect(formatDuration(5)).toBe('5 days');
|
||||
expect(formatDuration(7)).toBe('7 days');
|
||||
expect(formatDuration(14)).toBe('2 weeks');
|
||||
expect(formatDuration(21)).toBe('3 weeks');
|
||||
expect(formatDuration(30)).toBe('1 month');
|
||||
expect(formatDuration(90)).toBe('3 months');
|
||||
expect(formatDuration(360)).toBe('1 year');
|
||||
expect(formatDuration(540)).toBe('18 months');
|
||||
expect(formatDuration(720)).toBe('2 years');
|
||||
});
|
||||
|
||||
test('non-positive durations show an em-dash', () => {
|
||||
expect(formatDuration(0)).toBe('—');
|
||||
});
|
||||
|
||||
test('every slider stop produces a label (never an awkward day count past a month)', () => {
|
||||
DURATION_STOPS.forEach(days => {
|
||||
const label = formatDuration(days);
|
||||
expect(label).not.toBe('—');
|
||||
// Beyond a month we should never show a raw "N days" label.
|
||||
if (days > 30) {
|
||||
expect(label).not.toMatch(/day/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('constants', () => {
|
||||
const CATEGORIES = ['men', 'women', 'children', 'pregnant'];
|
||||
|
||||
test('presets define imperial and metric rates for every category', () => {
|
||||
for (const preset of Object.values(WATER_PRESETS)) {
|
||||
for (const system of [preset.imperial, preset.metric]) {
|
||||
for (const cat of CATEGORIES) {
|
||||
expect(system[cat]).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('comfortable preset is wetter than the minimum for every category', () => {
|
||||
const {minimum, comfortable} = WATER_PRESETS;
|
||||
for (const cat of CATEGORIES) {
|
||||
expect(comfortable.imperial[cat]).toBeGreaterThan(minimum.imperial[cat]);
|
||||
expect(comfortable.metric[cat]).toBeGreaterThan(minimum.metric[cat]);
|
||||
}
|
||||
});
|
||||
|
||||
test('pregnant/nursing rate exceeds the baseline woman rate', () => {
|
||||
for (const preset of Object.values(WATER_PRESETS)) {
|
||||
expect(preset.imperial.pregnant).toBeGreaterThan(preset.imperial.women);
|
||||
expect(preset.metric.pregnant).toBeGreaterThan(preset.metric.women);
|
||||
}
|
||||
});
|
||||
|
||||
test('every container has a unique key and positive capacities', () => {
|
||||
const keys = CONTAINERS.map(c => c.key);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
CONTAINERS.forEach(c => {
|
||||
expect(c.gallons).toBeGreaterThan(0);
|
||||
expect(c.liters).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('duration stops are strictly increasing and start at one day', () => {
|
||||
expect(DURATION_STOPS[0]).toBe(1);
|
||||
for (let i = 1; i < DURATION_STOPS.length; i++) {
|
||||
expect(DURATION_STOPS[i]).toBeGreaterThan(DURATION_STOPS[i - 1]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue