mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
Add "Count by Weight" to estimate item counts from weighing
Counting bulk hardware (screws, nails) by hand is slow. This adds a workflow
to estimate the count from weight instead: weigh one item, weigh the lot, and
the count is filled in automatically.
- The Fields modal gains a "Count by Weight" button that adds two quantity
fields, Unit Weight and Total Weight (default grams), and tags the Count
field as computed. An existing `count` field is linked in place rather than
duplicated, so hand-counted items keep their values.
- The link is plain metadata on the Count field
(`compute: {kind: 'count_by_weight', total, unit}`), so it round-trips
through the config/API with no backend change (covered by a backend test).
- During item entry, Count = round(Total Weight / Unit Weight), converting
mismatched units first (g/kg/oz) and refusing to divide when only one side
has a unit. It rounds to a whole number (no 403.7 nails).
- When a weight changes, the count always reflects the new reality: it is set
to the computed value (0 for a sub-unit total) when both weights are present,
or cleared when they can't produce one (a weight blanked, incompatible
units) — so a stale count can never persist against edited weights. A
hand-typed count is only touched when a weight actually changes.
countByWeight lives in units.js; the field preset, config detection, and the
entry-time apply hook live in a new computeFields.js. Covered by unit tests
(countByWeight, computeFields), InventoryTable auto-fill / clear / sub-unit on
draft and inline edit, the FieldSchemaEditor preset, and a backend
field-metadata round-trip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
447e4a40df
commit
305c73921e
9 changed files with 362 additions and 3 deletions
|
|
@ -4,6 +4,7 @@ import {
|
|||
} from "semantic-ui-react";
|
||||
import {Button, Icon, Modal, Table} from "../Theme";
|
||||
import {ALL_UNITS} from "./units";
|
||||
import {addCountByWeightFields, isCountByWeight} from "./computeFields";
|
||||
|
||||
const FIELD_TYPE_OPTIONS = ['text', 'number', 'quantity', 'date', 'select', 'location', 'calories']
|
||||
.map(t => ({key: t, value: t, text: t}));
|
||||
|
|
@ -39,6 +40,8 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
|
|||
return next;
|
||||
});
|
||||
const add = () => setDraft(prev => [...prev, {key: '', label: '', type: 'text'}]);
|
||||
// Add the "count by weight" fields (Unit Weight, Total Weight, computed Count); links an existing Count field.
|
||||
const addCountByWeight = () => setDraft(prev => addCountByWeightFields(prev));
|
||||
|
||||
const save = async () => {
|
||||
// Fill any missing keys from labels and normalize order.
|
||||
|
|
@ -108,6 +111,10 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
|
|||
<Input fluid placeholder='comma,separated,options'
|
||||
value={(f.options || []).join(',')}
|
||||
onChange={e => update(index, {options: e.target.value.split(',')})}/>}
|
||||
{isCountByWeight(f) &&
|
||||
<span style={{opacity: 0.7, fontSize: '0.9em'}}>
|
||||
<Icon name='calculator'/> Auto-counted: Total Weight ÷ Unit Weight
|
||||
</span>}
|
||||
</TableCell>
|
||||
<TableCell collapsing textAlign='center'>
|
||||
<Checkbox toggle checked={!!f.mobile} aria-label={`Show ${f.label || f.key} on mobile`}
|
||||
|
|
@ -121,6 +128,9 @@ export function FieldSchemaEditor({fields, open, onClose, onSave}) {
|
|||
</TableBody>
|
||||
</Table>
|
||||
<Button onClick={add}><Icon name='plus'/>Add Field</Button>
|
||||
<Button onClick={addCountByWeight} title='Add Unit Weight, Total Weight, and an auto-counted Count'>
|
||||
<Icon name='balance scale'/>Count by Weight
|
||||
</Button>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,24 @@ describe('FieldSchemaEditor select options', () => {
|
|||
expect(optionsInput.value).toBe('screws,nails');
|
||||
});
|
||||
|
||||
test('"Count by Weight" adds the linked weight + computed-count fields and they persist on save', async () => {
|
||||
const onSave = jest.fn();
|
||||
render(<FieldSchemaEditor fields={[{key: 'name', label: 'Name', type: 'text'}]}
|
||||
open={true} onClose={jest.fn()} onSave={onSave}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Count by Weight'));
|
||||
// The note appears on the computed Count row.
|
||||
expect(screen.getByText(/Total Weight ÷ Unit Weight/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByText('Save Fields'));
|
||||
const saved = onSave.mock.calls[0][0];
|
||||
const byKey = Object.fromEntries(saved.map(f => [f.key, f]));
|
||||
expect(byKey.unit_weight).toMatchObject({type: 'quantity', unit: 'g'});
|
||||
expect(byKey.total_weight).toMatchObject({type: 'quantity', unit: 'g'});
|
||||
// The compute metadata survives save() so the table can auto-fill the count.
|
||||
expect(byKey.count.compute).toEqual({kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'});
|
||||
});
|
||||
|
||||
test('options are trimmed and emptied-out on save', async () => {
|
||||
const onSave = jest.fn();
|
||||
render(<FieldSchemaEditor fields={FIELDS} open={true} onClose={jest.fn()} onSave={onSave}/>);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, {useMemo, useRef, useState} from "react";
|
|||
import {Checkbox, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
|
||||
import {Button, Icon, Table} from "../Theme";
|
||||
import {FieldCell} from "./FieldCell";
|
||||
import {applyComputedCounts, computedCountConfigs} from "./computeFields";
|
||||
|
||||
const LOCATION_LIST_ID = 'inventory-location-suggestions';
|
||||
const CATALOG_LIST_ID = 'inventory-catalog-names';
|
||||
|
|
@ -80,6 +81,8 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
|
|||
});
|
||||
return keys;
|
||||
}, [fields]);
|
||||
// "Count by weight" configs: a Count field auto-filled from a Total Weight ÷ Unit Weight.
|
||||
const countConfigs = useMemo(() => computedCountConfigs(fields), [fields]);
|
||||
|
||||
const listIdFor = (field) => {
|
||||
if (field.type === 'location') {
|
||||
|
|
@ -147,6 +150,8 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
|
|||
});
|
||||
}
|
||||
}
|
||||
// Auto-fill any "count by weight" field when a weight (or its unit) changed.
|
||||
applyComputedCounts(next, countConfigs, key);
|
||||
return next;
|
||||
});
|
||||
|
||||
|
|
@ -165,7 +170,11 @@ export function InventoryTable({slug, fields, items, locations, catalog, search,
|
|||
setFocusCell({id: item.id, key}); // the FieldCell for this key auto-focuses and selects its contents
|
||||
};
|
||||
const setEditValue = (id, key, value) =>
|
||||
setEdits(prev => ({...prev, [id]: {...prev[id], [key]: value}}));
|
||||
setEdits(prev => {
|
||||
const updated = {...prev[id], [key]: value};
|
||||
applyComputedCounts(updated, countConfigs, key);
|
||||
return {...prev, [id]: updated};
|
||||
});
|
||||
|
||||
const saveEdit = (id) => {
|
||||
onChange(items.map(i => i.id === id ? edits[id] : i));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from "react";
|
||||
import {fireEvent, render, screen} from "@testing-library/react";
|
||||
import {fireEvent, render, screen, within} from "@testing-library/react";
|
||||
import {filterItems, InventoryTable} from "./InventoryTable";
|
||||
|
||||
const FIELDS = [
|
||||
|
|
@ -78,6 +78,64 @@ describe('InventoryTable keyboard entry', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('InventoryTable count by weight', () => {
|
||||
const FIELDS = [
|
||||
{key: 'name', label: 'Name', type: 'text', order: 0},
|
||||
{key: 'unit_weight', label: 'Unit Weight', type: 'quantity', unit: 'g', order: 1},
|
||||
{key: 'total_weight', label: 'Total Weight', type: 'quantity', unit: 'g', order: 2},
|
||||
{key: 'count', label: 'Count', type: 'number', order: 3,
|
||||
compute: {kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}},
|
||||
];
|
||||
|
||||
test('entering the two weights in the draft row auto-fills the count', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<InventoryTable slug='s' fields={FIELDS} items={[]} locations={[]} onChange={onChange}/>);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Unit Weight'), {target: {value: '5'}});
|
||||
fireEvent.change(screen.getByLabelText('Total Weight'), {target: {value: '1000'}});
|
||||
|
||||
// The draft Count input is now pre-filled with 200 (1000 / 5).
|
||||
expect(screen.getByLabelText('Count').value).toBe('200');
|
||||
});
|
||||
|
||||
test('editing an existing item recomputes its count from the weights', () => {
|
||||
const onChange = jest.fn();
|
||||
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
|
||||
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
|
||||
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={onChange}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Screws')); // enter edit mode
|
||||
const totalInput = screen.getAllByLabelText('Total Weight').find(i => i.value === '1000');
|
||||
fireEvent.change(totalInput, {target: {value: '2000'}});
|
||||
|
||||
// The edited row's Count is recomputed to 400 (2000 / 5).
|
||||
expect(screen.getAllByLabelText('Count').some(i => i.value === '400')).toBe(true);
|
||||
});
|
||||
|
||||
test('clearing a weight in an existing row blanks the count (no stale value)', () => {
|
||||
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
|
||||
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
|
||||
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Screws')); // enter edit mode
|
||||
const row = screen.getByDisplayValue('Screws').closest('tr');
|
||||
// Clearing the total weight (as a real keyboard delete does) fires onChange with '' and blanks the count.
|
||||
fireEvent.change(within(row).getByLabelText('Total Weight'), {target: {value: ''}});
|
||||
expect(within(row).getByLabelText('Count').value).toBe('');
|
||||
});
|
||||
|
||||
test('reducing a weight below one item shows 0, not the stale count', () => {
|
||||
const items = [{id: 1, name: 'Screws', unit_weight: '5', unit_weight_unit: 'g',
|
||||
total_weight: '1000', total_weight_unit: 'g', count: '200'}];
|
||||
render(<InventoryTable slug='s' fields={FIELDS} items={items} locations={[]} onChange={jest.fn()}/>);
|
||||
|
||||
fireEvent.click(screen.getByText('Screws'));
|
||||
const row = screen.getByDisplayValue('Screws').closest('tr');
|
||||
fireEvent.change(within(row).getByLabelText('Total Weight'), {target: {value: '1'}});
|
||||
expect(within(row).getByLabelText('Count').value).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('InventoryTable sorting', () => {
|
||||
const items = [
|
||||
{id: 1, name: 'Rice', count: '5'},
|
||||
|
|
|
|||
91
app/src/components/inventory/computeFields.js
Normal file
91
app/src/components/inventory/computeFields.js
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {countByWeight} from "./units";
|
||||
|
||||
// "Count by weight" computed fields. A Count (number) field can carry metadata linking it to a Total Weight and a
|
||||
// Unit Weight field; the table then fills the count automatically from the two weights (count = round(total / unit)).
|
||||
// The metadata is a plain property on the field dict, so it round-trips through the config/API unchanged.
|
||||
|
||||
export const COUNT_BY_WEIGHT = 'count_by_weight';
|
||||
|
||||
// The fields the "Count by weight" preset creates.
|
||||
const UNIT_WEIGHT = {key: 'unit_weight', label: 'Unit Weight', type: 'quantity', unit: 'g'};
|
||||
const TOTAL_WEIGHT = {key: 'total_weight', label: 'Total Weight', type: 'quantity', unit: 'g'};
|
||||
const COUNT_COMPUTE = {kind: COUNT_BY_WEIGHT, total: 'total_weight', unit: 'unit_weight'};
|
||||
|
||||
// True when a field is a Count computed from weights.
|
||||
export function isCountByWeight(field) {
|
||||
return !!field && field.compute && field.compute.kind === COUNT_BY_WEIGHT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the "Count by weight" fields to a schema: Unit Weight and Total Weight (quantity, grams), plus a Count that
|
||||
* is tagged as computed. An existing `count` field is linked in place (so a Tool/Fastener inventory's Count just
|
||||
* becomes computed) rather than duplicated; existing weight fields are left untouched. Returns a new field list.
|
||||
*/
|
||||
export function addCountByWeightFields(fields) {
|
||||
const list = (fields || []).map(f => ({...f}));
|
||||
const has = (key) => list.some(f => f.key === key);
|
||||
|
||||
if (!has(UNIT_WEIGHT.key)) {
|
||||
list.push({...UNIT_WEIGHT});
|
||||
}
|
||||
if (!has(TOTAL_WEIGHT.key)) {
|
||||
list.push({...TOTAL_WEIGHT});
|
||||
}
|
||||
const existingCount = list.find(f => f.key === 'count');
|
||||
if (existingCount) {
|
||||
existingCount.compute = {...COUNT_COMPUTE};
|
||||
if (existingCount.type !== 'number') {
|
||||
existingCount.type = 'number';
|
||||
}
|
||||
} else {
|
||||
list.push({key: 'count', label: 'Count', type: 'number', compute: {...COUNT_COMPUTE}});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* The count-by-weight configs present in a schema, each resolved to the weight fields' keys and default units:
|
||||
* [{countKey, totalKey, unitKey, totalUnit, unitUnit}]. Skips configs whose referenced weight fields are missing.
|
||||
*/
|
||||
export function computedCountConfigs(fields) {
|
||||
return (fields || [])
|
||||
.filter(isCountByWeight)
|
||||
.map(f => {
|
||||
const totalField = (fields || []).find(x => x.key === f.compute.total);
|
||||
const unitField = (fields || []).find(x => x.key === f.compute.unit);
|
||||
if (!totalField || !unitField) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
countKey: f.key,
|
||||
totalKey: totalField.key,
|
||||
unitKey: unitField.key,
|
||||
totalUnit: totalField.unit || '',
|
||||
unitUnit: unitField.unit || '',
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute computed counts on an item after its field `changedKey` changed, mutating and returning the item.
|
||||
* Only acts when the change was to one of a count's weight inputs (value or unit), so a hand-typed count survives
|
||||
* edits to unrelated fields and is only touched when a weight actually changes. When a weight changes, the count
|
||||
* always reflects the new reality: it is set to the computed value (0 included) when both weights are present, or
|
||||
* cleared when they can't produce one (a weight blanked, incompatible units) — so a stale count can never persist
|
||||
* against edited weights.
|
||||
*/
|
||||
export function applyComputedCounts(item, configs, changedKey) {
|
||||
(configs || []).forEach(c => {
|
||||
const triggers = [c.totalKey, c.unitKey, `${c.totalKey}_unit`, `${c.unitKey}_unit`];
|
||||
if (!triggers.includes(changedKey)) {
|
||||
return;
|
||||
}
|
||||
const count = countByWeight(
|
||||
item[c.totalKey], item[`${c.totalKey}_unit`] || c.totalUnit,
|
||||
item[c.unitKey], item[`${c.unitKey}_unit`] || c.unitUnit,
|
||||
);
|
||||
item[c.countKey] = count === null ? '' : String(count);
|
||||
});
|
||||
return item;
|
||||
}
|
||||
92
app/src/components/inventory/computeFields.test.js
Normal file
92
app/src/components/inventory/computeFields.test.js
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import {addCountByWeightFields, applyComputedCounts, computedCountConfigs, isCountByWeight} from "./computeFields";
|
||||
|
||||
describe('addCountByWeightFields', () => {
|
||||
test('adds Unit Weight, Total Weight, and a computed Count to a schema with no count', () => {
|
||||
const result = addCountByWeightFields([{key: 'name', label: 'Name', type: 'text'}]);
|
||||
const byKey = Object.fromEntries(result.map(f => [f.key, f]));
|
||||
expect(byKey.unit_weight).toMatchObject({type: 'quantity', unit: 'g'});
|
||||
expect(byKey.total_weight).toMatchObject({type: 'quantity', unit: 'g'});
|
||||
expect(byKey.count.type).toBe('number');
|
||||
expect(byKey.count.compute).toEqual({kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'});
|
||||
});
|
||||
|
||||
test('links an existing count field instead of duplicating it', () => {
|
||||
const fields = [
|
||||
{key: 'name', label: 'Name', type: 'text'},
|
||||
{key: 'count', label: 'Count', type: 'number'},
|
||||
];
|
||||
const result = addCountByWeightFields(fields);
|
||||
expect(result.filter(f => f.key === 'count')).toHaveLength(1);
|
||||
expect(isCountByWeight(result.find(f => f.key === 'count'))).toBe(true);
|
||||
// The two weight fields were appended.
|
||||
expect(result.map(f => f.key)).toEqual(['name', 'count', 'unit_weight', 'total_weight']);
|
||||
});
|
||||
|
||||
test('does not duplicate existing weight fields', () => {
|
||||
const fields = [{key: 'unit_weight', type: 'quantity', unit: 'oz'}];
|
||||
const result = addCountByWeightFields(fields);
|
||||
expect(result.filter(f => f.key === 'unit_weight')).toHaveLength(1);
|
||||
expect(result.find(f => f.key === 'unit_weight').unit).toBe('oz'); // left untouched
|
||||
});
|
||||
});
|
||||
|
||||
describe('computedCountConfigs', () => {
|
||||
const FIELDS = [
|
||||
{key: 'unit_weight', type: 'quantity', unit: 'g'},
|
||||
{key: 'total_weight', type: 'quantity', unit: 'g'},
|
||||
{key: 'count', type: 'number', compute: {kind: 'count_by_weight', total: 'total_weight', unit: 'unit_weight'}},
|
||||
];
|
||||
|
||||
test('resolves the weight keys and their default units', () => {
|
||||
expect(computedCountConfigs(FIELDS)).toEqual([
|
||||
{countKey: 'count', totalKey: 'total_weight', unitKey: 'unit_weight', totalUnit: 'g', unitUnit: 'g'},
|
||||
]);
|
||||
});
|
||||
|
||||
test('skips a config whose referenced weight field is missing', () => {
|
||||
const broken = [{key: 'count', type: 'number',
|
||||
compute: {kind: 'count_by_weight', total: 'gone', unit: 'unit_weight'}}];
|
||||
expect(computedCountConfigs(broken)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyComputedCounts', () => {
|
||||
const configs = [{countKey: 'count', totalKey: 'total_weight', unitKey: 'unit_weight',
|
||||
totalUnit: 'g', unitUnit: 'g'}];
|
||||
|
||||
test('fills the count when a weight changes', () => {
|
||||
const item = {total_weight: '1000', unit_weight: '5'};
|
||||
applyComputedCounts(item, configs, 'total_weight');
|
||||
expect(item.count).toBe('200');
|
||||
});
|
||||
|
||||
test('uses the per-item unit override when present', () => {
|
||||
const item = {total_weight: '2', total_weight_unit: 'kg', unit_weight: '5', unit_weight_unit: 'g'};
|
||||
applyComputedCounts(item, configs, 'unit_weight');
|
||||
expect(item.count).toBe('400');
|
||||
});
|
||||
|
||||
test('does not recompute when an unrelated field changes (manual count survives)', () => {
|
||||
const item = {total_weight: '1000', unit_weight: '5', count: '999', name: 'Screws'};
|
||||
applyComputedCounts(item, configs, 'name');
|
||||
expect(item.count).toBe('999');
|
||||
});
|
||||
|
||||
test('reducing a weight to a sub-unit total recomputes the count to 0 (no stale value)', () => {
|
||||
const item = {total_weight: '1', unit_weight: '5', count: '200'};
|
||||
applyComputedCounts(item, configs, 'total_weight');
|
||||
expect(item.count).toBe('0');
|
||||
});
|
||||
|
||||
test('clearing a weight clears the count so a stale value cannot persist', () => {
|
||||
const item = {total_weight: '', unit_weight: '5', count: '200'};
|
||||
applyComputedCounts(item, configs, 'total_weight');
|
||||
expect(item.count).toBe('');
|
||||
});
|
||||
|
||||
test('a fresh draft (no count yet) stays blank while only one weight is entered', () => {
|
||||
const item = {total_weight: '', unit_weight: '5', count: ''};
|
||||
applyComputedCounts(item, configs, 'unit_weight');
|
||||
expect(item.count).toBe('');
|
||||
});
|
||||
});
|
||||
|
|
@ -54,6 +54,35 @@ export function convert(value, fromUnit, toUnitName) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate how many items a lot contains from a total weight and a single item's weight:
|
||||
* count = round(total / unit), converting `total` into the unit-weight's unit first (so e.g. a 2 kg total and a
|
||||
* 5 g unit weight give 400). The result is rounded to a whole number — you can't have 403.7 nails. A total below
|
||||
* one item (or zero) gives 0, not null, so a corrected-down weight doesn't leave a stale count behind.
|
||||
* Returns null when a weight is blank, the unit weight is non-positive, or the units are incompatible.
|
||||
*/
|
||||
export function countByWeight(total, totalUnit, unitWeight, unitWeightUnit) {
|
||||
const each = Number(unitWeight);
|
||||
if (!(each > 0) || total === '' || total === null || total === undefined) {
|
||||
return null;
|
||||
}
|
||||
let totalInEachUnit;
|
||||
if (totalUnit && unitWeightUnit && totalUnit !== unitWeightUnit) {
|
||||
totalInEachUnit = convert(total, totalUnit, unitWeightUnit); // null if incompatible
|
||||
} else if ((totalUnit || '') === (unitWeightUnit || '')) {
|
||||
// Same unit (or both unitless) — divide the raw magnitudes.
|
||||
const t = Number(total);
|
||||
totalInEachUnit = Number.isFinite(t) ? t : null;
|
||||
} else {
|
||||
// One side has a unit and the other doesn't — can't safely divide, so refuse to guess.
|
||||
return null;
|
||||
}
|
||||
if (totalInEachUnit === null || !Number.isFinite(totalInEachUnit) || totalInEachUnit < 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(totalInEachUnit / each); // 0 for a sub-unit (or zero) total
|
||||
}
|
||||
|
||||
function compact(unitValue) {
|
||||
// unitValue is a mathjs Unit. On a compatible ladder, pick the largest unit where the value is still >= 1.
|
||||
for (const ladder of COMPACTION_LADDERS) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,36 @@
|
|||
import {convert, formatTotals, sumQuantities} from "./units";
|
||||
import {convert, countByWeight, formatTotals, sumQuantities} from "./units";
|
||||
|
||||
describe('countByWeight', () => {
|
||||
test('divides total by unit weight and rounds to a whole number', () => {
|
||||
expect(countByWeight('1000', 'g', '5', 'g')).toBe(200);
|
||||
// 403.7 nails -> 404 (rounded to a whole number).
|
||||
expect(countByWeight('1009.25', 'g', '2.5', 'g')).toBe(404);
|
||||
});
|
||||
|
||||
test('converts mismatched units before dividing', () => {
|
||||
expect(countByWeight('2', 'kg', '5', 'g')).toBe(400); // 2 kg / 5 g
|
||||
expect(countByWeight('1', 'lb', '1', 'oz')).toBe(16);
|
||||
});
|
||||
|
||||
test('a sub-unit or zero total yields a count of 0 (not null)', () => {
|
||||
expect(countByWeight('1', 'g', '5', 'g')).toBe(0); // round(0.2) = 0
|
||||
expect(countByWeight('0', 'g', '5', 'g')).toBe(0);
|
||||
});
|
||||
|
||||
test('returns null when a weight is missing, zero, or incompatible', () => {
|
||||
expect(countByWeight('', 'g', '5', 'g')).toBeNull();
|
||||
expect(countByWeight('1000', 'g', '', 'g')).toBeNull();
|
||||
expect(countByWeight('1000', 'g', '0', 'g')).toBeNull();
|
||||
expect(countByWeight('1000', 'lb', '5', 'gallon')).toBeNull(); // incompatible dimensions
|
||||
});
|
||||
|
||||
test('refuses to divide when only one side has a unit', () => {
|
||||
expect(countByWeight('2', '', '1', 'oz')).toBeNull();
|
||||
expect(countByWeight('2', 'oz', '1', '')).toBeNull();
|
||||
// Both unitless divides the raw magnitudes.
|
||||
expect(countByWeight('100', '', '4', '')).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convert', () => {
|
||||
test('converts within mass', () => {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,26 @@ def test_save_inventory_fields(food_inventory_factory, test_inventory_configs):
|
|||
assert [f['order'] for f in saved['fields']] == [0, 1]
|
||||
|
||||
|
||||
def test_save_inventory_fields_preserve_compute_metadata(food_inventory_factory, test_inventory_configs):
|
||||
"""A field's extra `compute` metadata (e.g. count-by-weight) must round-trip through save and re-import."""
|
||||
config = test_inventory_configs
|
||||
slug = food_inventory_factory()
|
||||
compute = {'kind': 'count_by_weight', 'total': 'total_weight', 'unit': 'unit_weight'}
|
||||
saved = config.save_inventory(slug, dict(fields=[
|
||||
dict(key='name', label='Name', type='text'),
|
||||
dict(key='unit_weight', label='Unit Weight', type='quantity', unit='g'),
|
||||
dict(key='total_weight', label='Total Weight', type='quantity', unit='g'),
|
||||
dict(key='count', label='Count', type='number', compute=compute),
|
||||
]))
|
||||
assert next(f for f in saved['fields'] if f['key'] == 'count')['compute'] == compute
|
||||
|
||||
# A fresh config instance re-reads from disk; the metadata persists.
|
||||
fresh = inventory_common.InventoriesConfig()
|
||||
fresh.initialize()
|
||||
restored = fresh.get_inventory(slug)
|
||||
assert next(f for f in restored['fields'] if f['key'] == 'count')['compute'] == compute
|
||||
|
||||
|
||||
def test_save_inventory_rename_keeps_slug(food_inventory_factory, test_inventory_configs):
|
||||
config = test_inventory_configs
|
||||
slug = food_inventory_factory(name='Food Storage')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue