mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-25 18:40:06 -04:00
Add a recommended Food Storage example, a Ration tab, and a supply planner
Build on the rebuilt config-only Inventory with three related additions.
Recommended example inventory:
- A fresh install now seeds the "Food Storage" example with the recommended
one-adult / one-year (~3,000 kcal/day) emergency food supply (60 items,
~1.12M kcal), instead of an empty inventory.
- The counts are layered on top of the shipped food catalog (single source of
per-package nutrition) via RECOMMENDED_ONE_YEAR_FOOD_COUNTS, joined at seed
time by recommended_food_storage_items().
- Genericized the two remaining brand names in the catalog defaults
(Velveeta -> Processed Cheese, Spam -> Canned Lunch Meat) so both the catalog
and the seeded inventory stay brand-free.
Ration tab (split out of Summary):
- Moved the Ration Estimate (People / Daily Calories / estimate table) out of
the increasingly long Summary tab into its own "Ration" tab, shown only for
inventories that have a calories field.
Supply planner ("Plan More Supply"):
- A slider picks a target duration longer than the current estimate; the whole
inventory is scaled up proportionally (every item by the same factor, mix
kept balanced) into a sortable shopping list of what to buy.
- Any split package rounds UP to a whole one (ceil), matching the "1.6 buckets
-> buy 1 more" rule.
- The shopping list is exportable as CSV (reusing toCSV) and PDF (reusing the
print-to-PDF path, scoped via body.printing-shopping so only the list prints
-- no inventory or summary).
Search filter:
- Moved the items search out of the top toolbar (which implied it filtered
every tab) into the Items tab, so it clearly narrows Items only;
Summary/Ration/Export/print always use the full inventory.
Aggregation/role helpers (findNameKey, planSupplyPurchase) live in summarize.js,
the single source of truth shared by Summary, export, and the ration views.
All new pure functions and UI flows are covered by tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5427beb5fb
commit
ee0b827c71
14 changed files with 545 additions and 38 deletions
|
|
@ -543,4 +543,29 @@ tr.drag-deselecting td {
|
|||
.inventory-print tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* The supply-plan shopping list reuses the .inventory-print element styling (h1/table/etc.) but is a separate
|
||||
printable block. By default (e.g. the Export tab's whole-inventory PDF) it is hidden; it becomes the ONLY
|
||||
printed block when the supply plan toggles `body.printing-shopping` just before window.print(). */
|
||||
.shopping-print,
|
||||
.shopping-print * {
|
||||
visibility: hidden;
|
||||
}
|
||||
.shopping-print {
|
||||
display: none;
|
||||
}
|
||||
body.printing-shopping .inventory-print:not(.shopping-print),
|
||||
body.printing-shopping .inventory-print:not(.shopping-print) * {
|
||||
visibility: hidden;
|
||||
}
|
||||
body.printing-shopping .inventory-print:not(.shopping-print) {
|
||||
display: none;
|
||||
}
|
||||
body.printing-shopping .shopping-print,
|
||||
body.printing-shopping .shopping-print * {
|
||||
visibility: visible;
|
||||
}
|
||||
body.printing-shopping .shopping-print {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import React from "react";
|
||||
import {GridColumn, Input, Label, TableBody, TableCell, TableRow} from "semantic-ui-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, Table} from "../Theme";
|
||||
import {Button, Form, Header, Icon, Table} from "../Theme";
|
||||
import {InfoPopup, roundDigits, useLocalStorage} from "../Common";
|
||||
import {formatDuration} from "./WaterCalculator";
|
||||
import {findNameKey, planSupplyPurchase} from "../inventory/summarize";
|
||||
import {downloadCSV, inventoryExportFilename, shoppingListCSV} from "../inventory/inventoryExport";
|
||||
import {ThemeContext} from "../../contexts/contexts";
|
||||
// Field-role detection lives in inventory/summarize.js (single source of truth); re-exported here for back-compat.
|
||||
export {findCaloriesKey, findCountKey} from "../inventory/summarize";
|
||||
|
||||
|
|
@ -109,7 +112,7 @@ function fmt(value, unit) {
|
|||
* inventory Summary (when the inventory has a `calories` field). Household/preset selections are persisted under
|
||||
* shared localStorage keys, so adjusting them in one place carries to the other.
|
||||
*/
|
||||
export function RationEstimatePanel({items, caloriesKey, countKey}) {
|
||||
export function RationEstimatePanel({name, items, fields, caloriesKey, countKey}) {
|
||||
const [preset, setPreset] = useLocalStorage('ration_preset', DEFAULT_PRESET);
|
||||
const [counts, setCounts] = useLocalStorage('ration_counts', {men: '1', women: '1', children: ''});
|
||||
const [rates, setRates] = React.useState({...RATION_PRESETS[DEFAULT_PRESET].rates});
|
||||
|
|
@ -178,8 +181,167 @@ export function RationEstimatePanel({items, caloriesKey, countKey}) {
|
|||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<SupplyPlan name={name} items={items} fields={fields} caloriesKey={caloriesKey} countKey={countKey}
|
||||
currentDays={days} total={total} daily={daily}/>
|
||||
</Form>;
|
||||
}
|
||||
|
||||
// Print only the supply-plan shopping list (not the whole-inventory print block): toggle a body class that the
|
||||
// `@media print` rules use to pick which printable block is visible, then open the print dialog.
|
||||
function printShoppingList() {
|
||||
document.body.classList.add('printing-shopping');
|
||||
let timer;
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer); // ensure the backstop can't fire later and clear the class during a new print
|
||||
window.removeEventListener('afterprint', cleanup);
|
||||
document.body.classList.remove('printing-shopping');
|
||||
};
|
||||
window.addEventListener('afterprint', cleanup, {once: true});
|
||||
window.print();
|
||||
// Backstop in case afterprint never fires (cancelled above once afterprint runs), so a later whole-inventory
|
||||
// print isn't stuck in shopping mode.
|
||||
timer = setTimeout(cleanup, 1000);
|
||||
}
|
||||
|
||||
// Length of a "month" used for the supply plan, matching formatDuration()'s 30-day months so the slider label and
|
||||
// the duration text agree.
|
||||
const PLAN_MONTH_DAYS = 30;
|
||||
|
||||
const PLAN_INFO = 'Drag the slider to a target duration longer than your current estimate to see what to buy. '
|
||||
+ 'Your whole inventory is scaled up proportionally — every item grows by the same factor so the mix stays '
|
||||
+ 'balanced — and any split package is rounded up to a whole one. The projection assumes you keep the same '
|
||||
+ 'variety of food and the same household/activity settings above.';
|
||||
|
||||
/**
|
||||
* Extrapolation: a slider to pick a target duration longer than the current ration estimate, and a shopping list of
|
||||
* the additional packages to buy to reach it (the inventory scaled up proportionally — see planSupplyPurchase).
|
||||
*/
|
||||
function SupplyPlan({name, items, fields, caloriesKey, countKey, currentDays, total, daily}) {
|
||||
const {t} = React.useContext(ThemeContext);
|
||||
const nameKey = findNameKey(fields);
|
||||
|
||||
const currentMonths = (currentDays || 0) / PLAN_MONTH_DAYS;
|
||||
const minMonths = Math.max(1, Math.ceil(currentMonths));
|
||||
const maxMonths = minMonths + 24;
|
||||
const [targetMonths, setTargetMonths] = React.useState(minMonths);
|
||||
// Keep the target within range as the estimate shifts (editing the household above moves the current duration).
|
||||
React.useEffect(() => {
|
||||
setTargetMonths(m => Math.min(Math.max(m, minMonths), maxMonths));
|
||||
}, [minMonths, maxMonths]);
|
||||
// Default to largest purchase first; clicking a header re-sorts.
|
||||
const [sort, setSort] = React.useState({key: 'additional', dir: 'desc'});
|
||||
const toggleSort = (key) => setSort(prev =>
|
||||
prev.key === key
|
||||
? {key, dir: prev.dir === 'asc' ? 'desc' : 'asc'}
|
||||
: {key, dir: key === 'name' ? 'asc' : 'desc'});
|
||||
|
||||
if (!(currentDays > 0)) {
|
||||
return null; // No usable estimate yet (no calories or no household) — nothing to extrapolate from.
|
||||
}
|
||||
|
||||
const targetDays = targetMonths * PLAN_MONTH_DAYS;
|
||||
const scale = targetDays / currentDays;
|
||||
const {rows, addedCalories} = planSupplyPurchase(items, {countKey, nameKey, caloriesKey, scale});
|
||||
const projectedDays = daily > 0 ? (total + addedCalories) / daily : null;
|
||||
const totalToBuy = rows.reduce((sum, r) => sum + r.additional, 0);
|
||||
|
||||
const sortedRows = [...rows].sort((a, b) => {
|
||||
const r = sort.key === 'name' ? a.name.localeCompare(b.name) : a[sort.key] - b[sort.key];
|
||||
return sort.dir === 'desc' ? -r : r;
|
||||
});
|
||||
const sortDir = (key) => sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined;
|
||||
const headerCell = (key, label) =>
|
||||
<TableHeaderCell sorted={sortDir(key)} onClick={() => toggleSort(key)} style={{cursor: 'pointer'}}>
|
||||
{label}
|
||||
</TableHeaderCell>;
|
||||
|
||||
return <div style={{marginTop: '2em'}}>
|
||||
<Header as='h3'>Plan More Supply <InfoPopup content={PLAN_INFO}/></Header>
|
||||
{!countKey
|
||||
? <p {...t}>Add a <strong>Count</strong> field to this inventory to plan purchases.</p>
|
||||
: <>
|
||||
<div style={{padding: '0 0.5em'}}>
|
||||
<input type='range' min={minMonths} max={maxMonths} step={1} value={targetMonths}
|
||||
aria-label='Target duration (months)' style={{width: '100%'}}
|
||||
onChange={e => setTargetMonths(Number(e.target.value))}/>
|
||||
<Header as='h2' textAlign='center' style={{marginTop: '0.25em'}}>
|
||||
Target: {formatDuration(targetDays)}
|
||||
</Header>
|
||||
<p {...t} style={{...t.style, textAlign: 'center', opacity: 0.8}}>
|
||||
Currently {formatDuration(currentDays)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{rows.length === 0
|
||||
? <p {...t}>Drag the slider above your current estimate to see a shopping list.</p>
|
||||
: <>
|
||||
<Table celled unstackable sortable>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{headerCell('name', 'Item')}
|
||||
{headerCell('current', 'Have')}
|
||||
{headerCell('additional', 'Buy')}
|
||||
{headerCell('target', 'New Total')}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedRows.map((r, i) => <TableRow key={`${r.name}-${i}`}>
|
||||
<TableCell>{r.name}</TableCell>
|
||||
<TableCell>{r.current}</TableCell>
|
||||
<TableCell><strong>+{r.additional}</strong></TableCell>
|
||||
<TableCell>{r.target}</TableCell>
|
||||
</TableRow>)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<p {...t}>
|
||||
Buy <strong>{totalToBuy.toLocaleString()}</strong> additional package{totalToBuy === 1 ? '' : 's'}
|
||||
{' '}across <strong>{rows.length}</strong> item{rows.length === 1 ? '' : 's'}
|
||||
{projectedDays ? <> to reach about <strong>{formatDuration(projectedDays)}</strong> of food.</> : '.'}
|
||||
</p>
|
||||
|
||||
<Button primary
|
||||
onClick={() => downloadCSV(
|
||||
inventoryExportFilename(`${name || 'inventory'} shopping list`, 'csv'),
|
||||
shoppingListCSV(sortedRows))}>
|
||||
<Icon name='download'/> Download CSV
|
||||
</Button>
|
||||
<Button onClick={printShoppingList}>
|
||||
<Icon name='print'/> Print / Save as PDF
|
||||
</Button>
|
||||
|
||||
{/* Hidden printable block — `printShoppingList` makes this the only thing printed. */}
|
||||
<ShoppingListPrint name={name} rows={sortedRows}
|
||||
targetText={formatDuration(targetDays)}
|
||||
currentText={formatDuration(currentDays)}/>
|
||||
</>}
|
||||
</>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
// Printable rendering of just the shopping list (no inventory or summary), shown only when printing via the
|
||||
// `body.printing-shopping` toggle. Reuses the `.inventory-print` styling for headings and the table.
|
||||
function ShoppingListPrint({name, rows, targetText, currentText}) {
|
||||
return <div className='inventory-print shopping-print'>
|
||||
<h1>{name || 'Inventory'} — Shopping List</h1>
|
||||
<p className='inventory-print-meta'>
|
||||
Target {targetText} (currently {currentText}) · {rows.length} item{rows.length === 1 ? '' : 's'} to buy
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Item</th><th>Have</th><th>Buy</th><th>New Total</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => <tr key={`${r.name}-${i}`}>
|
||||
<td>{r.name}</td>
|
||||
<td>{r.current}</td>
|
||||
<td>+{r.additional}</td>
|
||||
<td>{r.target}</td>
|
||||
</tr>)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>;
|
||||
}
|
||||
|
||||
// NOTE: the inventory-based "how long does my food last" estimate now lives only inside the inventory Summary
|
||||
// (via RationEstimatePanel). The standalone Calculators page is the generic FoodStorageCalculator instead.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import {dailyCalorieDemand, daysOfFood, findCaloriesKey, findCountKey, totalCalories} from "./RationCalculator";
|
||||
import React from "react";
|
||||
import {fireEvent, render, screen, within} from "@testing-library/react";
|
||||
import {
|
||||
dailyCalorieDemand, daysOfFood, findCaloriesKey, findCountKey, RationEstimatePanel, totalCalories,
|
||||
} from "./RationCalculator";
|
||||
|
||||
describe('findCaloriesKey', () => {
|
||||
test('detects a calories-type field', () => {
|
||||
|
|
@ -71,3 +75,76 @@ describe('daysOfFood', () => {
|
|||
expect(daysOfFood(14000, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('RationEstimatePanel supply plan', () => {
|
||||
const FIELDS = [
|
||||
{key: 'name', label: 'Name', type: 'text', order: 0},
|
||||
{key: 'count', label: 'Count', type: 'number', order: 1},
|
||||
{key: 'calories', label: 'kcal', type: 'calories', order: 2},
|
||||
];
|
||||
// 100 cans × 450 kcal = 45,000 kcal; with a survival ration for one man (1,500/day) that lasts exactly 30 days.
|
||||
const ITEMS = [{name: 'Beans', count: '100', calories: '450'}];
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
// Deterministic household: one man on the Survival preset → 1,500 kcal/day.
|
||||
window.localStorage.setItem('ration_preset', JSON.stringify('survival'));
|
||||
window.localStorage.setItem('ration_counts', JSON.stringify({men: '1', women: '', children: ''}));
|
||||
});
|
||||
|
||||
test('dragging the target slider produces a round-up shopping list', () => {
|
||||
render(<RationEstimatePanel items={ITEMS} fields={FIELDS} caloriesKey='calories' countKey='count'/>);
|
||||
|
||||
// Current estimate is 1 month, so the slider starts there with no purchases yet.
|
||||
expect(screen.getByText(/drag the slider/i)).toBeTruthy();
|
||||
|
||||
// Drag to a 2-month target: scale ×2 → 100 → 200 cans, buy 100.
|
||||
fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}});
|
||||
|
||||
// Scope to the on-screen table (the hidden print block duplicates these texts in the DOM).
|
||||
const plan = within(document.querySelector('table.sortable'));
|
||||
expect(plan.getByText('Beans')).toBeTruthy();
|
||||
expect(plan.getByText('+100')).toBeTruthy();
|
||||
// Summary line reports the total to buy.
|
||||
expect(screen.getByText(/additional packages/).textContent).toMatch(/100 additional packages/);
|
||||
});
|
||||
|
||||
test('clicking a column header re-sorts the shopping list', () => {
|
||||
const items = [
|
||||
{name: 'Apples', count: '10', calories: '100'},
|
||||
{name: 'Beans', count: '200', calories: '100'},
|
||||
];
|
||||
render(<RationEstimatePanel items={items} fields={FIELDS} caloriesKey='calories' countKey='count'/>);
|
||||
fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}});
|
||||
|
||||
const itemNames = () =>
|
||||
[...document.querySelectorAll('table.sortable tbody tr td:first-child')].map(td => td.textContent);
|
||||
// Default sort is largest purchase first (Beans buys far more than Apples).
|
||||
expect(itemNames()).toEqual(['Beans', 'Apples']);
|
||||
|
||||
// Sorting by Item ascending flips to alphabetical (scope to the sortable table; the print block also has an
|
||||
// "Item" header).
|
||||
fireEvent.click(within(document.querySelector('table.sortable')).getByText('Item'));
|
||||
expect(itemNames()).toEqual(['Apples', 'Beans']);
|
||||
});
|
||||
|
||||
test('a populated plan offers CSV and PDF export, and printing scopes to the shopping list', () => {
|
||||
const printSpy = jest.spyOn(window, 'print').mockImplementation(() => {});
|
||||
render(<RationEstimatePanel name='Food Storage' items={ITEMS} fields={FIELDS}
|
||||
caloriesKey='calories' countKey='count'/>);
|
||||
fireEvent.change(screen.getByLabelText(/target duration/i), {target: {value: '2'}});
|
||||
|
||||
expect(screen.getByText(/Download CSV/i)).toBeTruthy();
|
||||
|
||||
// Printing toggles the body class the @media print rules use to show only the shopping list.
|
||||
fireEvent.click(screen.getByText(/Print/i));
|
||||
expect(printSpy).toHaveBeenCalled();
|
||||
expect(document.body.classList.contains('printing-shopping')).toBe(true);
|
||||
printSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('without a count field, the plan asks for one instead of a list', () => {
|
||||
render(<RationEstimatePanel items={ITEMS} fields={FIELDS} caloriesKey='calories' countKey={null}/>);
|
||||
expect(screen.getByText(/add a/i).textContent.toLowerCase()).toContain('count');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import {InventoryItemsMobile} from "./InventoryItemsMobile";
|
|||
import {InventorySummary} from "./InventorySummary";
|
||||
import {InventoryExportPanel} from "./InventoryExportPanel";
|
||||
import {InventoryPrint} from "./InventoryPrint";
|
||||
import {defaultGroupKey, defaultSumKey} from "./summarize";
|
||||
import {RationEstimatePanel} from "../calculators/RationCalculator";
|
||||
import {defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey} from "./summarize";
|
||||
import {FieldSchemaEditor} from "./FieldSchemaEditor";
|
||||
import {CatalogEditor} from "./CatalogEditor";
|
||||
import {Media} from "../../contexts/contexts";
|
||||
|
|
@ -82,6 +83,9 @@ export function InventoryRoute() {
|
|||
const current = inventories?.find(i => i.slug === slug);
|
||||
const fields = useMemo(() => (current ? current.fields : []), [current]);
|
||||
const items = useMemo(() => (current ? current.items : []), [current]);
|
||||
// The Ration tab (estimate + supply plan) only applies to inventories with a calories field.
|
||||
const caloriesKey = useMemo(() => findCaloriesKey(fields), [fields]);
|
||||
const countKey = useMemo(() => findCountKey(fields), [fields]);
|
||||
|
||||
// Re-seed the export grouping when the selected inventory (or its schema) changes.
|
||||
React.useEffect(() => {
|
||||
|
|
@ -90,9 +94,16 @@ export function InventoryRoute() {
|
|||
}, [fields]);
|
||||
// Clear the search when switching inventories.
|
||||
React.useEffect(() => setSearch(''), [slug]);
|
||||
// The Ration tab disappears for inventories without a calories field; fall back to Items if it was active.
|
||||
React.useEffect(() => {
|
||||
if (tab === 'ration' && !caloriesKey) {
|
||||
setTab('items');
|
||||
}
|
||||
}, [tab, caloriesKey]);
|
||||
|
||||
// The search narrows the whole inventory view (Items display, Summary, Export); edits/adds in InventoryTable
|
||||
// still operate on the full `items`, so filtering never drops data.
|
||||
// The search lives in (and only narrows) the Items tab. It feeds the read-only mobile list here; the desktop
|
||||
// table filters its own display from the full `items` it receives. Summary/Ration/Export always use the full
|
||||
// inventory, so the filter never silently hides data on those tabs.
|
||||
const filteredItems = useMemo(() => filterItems(items, fields, search), [items, fields, search]);
|
||||
|
||||
// Location suggestions are pooled across every inventory.
|
||||
|
|
@ -155,20 +166,22 @@ export function InventoryRoute() {
|
|||
</Button>
|
||||
</>}
|
||||
</div>
|
||||
{current &&
|
||||
<Input fluid icon='search' iconPosition='left' placeholder='Search items…' value={search}
|
||||
aria-label='Search items' clearable style={{marginTop: '0.75em'}}
|
||||
onChange={(e, data) => setSearch(data.value)}/>}
|
||||
</Segment>
|
||||
|
||||
{current ? <>
|
||||
<Menu pointing secondary>
|
||||
<Menu.Item name='Items' active={tab === 'items'} onClick={() => setTab('items')}/>
|
||||
<Menu.Item name='Summary' active={tab === 'summary'} onClick={() => setTab('summary')}/>
|
||||
{caloriesKey &&
|
||||
<Menu.Item name='Ration' active={tab === 'ration'} onClick={() => setTab('ration')}/>}
|
||||
<Menu.Item name='Export' active={tab === 'export'} onClick={() => setTab('export')}/>
|
||||
</Menu>
|
||||
|
||||
{tab === 'items' && <>
|
||||
{/* The search filter lives here so it clearly applies to the Items tab only, not Summary/Ration/Export. */}
|
||||
<Input fluid icon='search' iconPosition='left' placeholder='Search items…' value={search}
|
||||
aria-label='Search items' clearable style={{marginBottom: '0.75em'}}
|
||||
onChange={(e, data) => setSearch(data.value)}/>
|
||||
{/* Portrait mobile: condensed, read-only. Rotate to landscape (tablet+) for the full editor. */}
|
||||
<Media at='mobile'>
|
||||
<InventoryItemsMobile fields={fields} items={filteredItems}/>
|
||||
|
|
@ -181,14 +194,17 @@ export function InventoryRoute() {
|
|||
onChange={newItems => persistInventory(slug, {items: newItems})}/>
|
||||
</Media>
|
||||
</>}
|
||||
{tab === 'summary' && <InventorySummary fields={fields} items={filteredItems}/>}
|
||||
{tab === 'summary' && <InventorySummary fields={fields} items={items}/>}
|
||||
{tab === 'ration' && caloriesKey &&
|
||||
<RationEstimatePanel name={current.name} fields={fields} items={items}
|
||||
caloriesKey={caloriesKey} countKey={countKey}/>}
|
||||
{tab === 'export' &&
|
||||
<InventoryExportPanel name={current.name} fields={fields} items={filteredItems}
|
||||
<InventoryExportPanel name={current.name} fields={fields} items={items}
|
||||
groupKey={exportGroupKey} sumKey={exportSumKey}
|
||||
onGroupKey={setExportGroupKey} onSumKey={setExportSumKey}/>}
|
||||
|
||||
{/* Always mounted (hidden on screen) so the browser print dialog has the full table + summary to render. */}
|
||||
<InventoryPrint name={current.name} fields={fields} items={filteredItems}
|
||||
<InventoryPrint name={current.name} fields={fields} items={items}
|
||||
groupKey={exportGroupKey} sumKey={exportSumKey}/>
|
||||
|
||||
<FieldSchemaEditor fields={fields} open={editFieldsOpen}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, {useMemo, useState} from "react";
|
||||
import {Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
|
||||
import {Header, Segment, Table} from "../Theme";
|
||||
import {RationEstimatePanel} from "../calculators/RationCalculator";
|
||||
import {Header, Table} from "../Theme";
|
||||
import {
|
||||
defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey, groupFieldsOf, sortSummaryRows,
|
||||
summableFieldsOf, summarizeInventory,
|
||||
|
|
@ -9,8 +8,8 @@ import {
|
|||
import {ThemeContext} from "../../contexts/contexts";
|
||||
|
||||
// Aggregate the inventory client-side: group items by a chosen text/select field and sum a chosen quantity, number,
|
||||
// or calories field. When the inventory has a `calories` field, a ration estimate is shown below the summary table.
|
||||
// The grouping math lives in summarize.js (shared with the PDF export).
|
||||
// or calories field. The grouping math lives in summarize.js (shared with the PDF export). The ration estimate
|
||||
// lives in its own "Ration" tab (see RationEstimatePanel).
|
||||
export function InventorySummary({fields, items}) {
|
||||
const groupFields = groupFieldsOf(fields);
|
||||
const summableFields = summableFieldsOf(fields);
|
||||
|
|
@ -81,11 +80,5 @@ export function InventorySummary({fields, items}) {
|
|||
</TableRow>)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{caloriesKey &&
|
||||
<Segment style={{marginTop: '2em'}}>
|
||||
<Header as='h3'>Ration Estimate</Header>
|
||||
<RationEstimatePanel items={items} caloriesKey={caloriesKey} countKey={countKey}/>
|
||||
</Segment>}
|
||||
</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,21 @@ export function toCSV(fields, items) {
|
|||
return [header, ...rows].map(row => row.join(',')).join('\r\n');
|
||||
}
|
||||
|
||||
// Column layout for an exported supply-plan shopping list, mirroring the on-screen table. No `type` is set, so
|
||||
// the shared CSV writer formats every value as plain text/number.
|
||||
const SHOPPING_LIST_FIELDS = [
|
||||
{key: 'name', label: 'Item'},
|
||||
{key: 'current', label: 'Have'},
|
||||
{key: 'additional', label: 'Buy'},
|
||||
{key: 'target', label: 'New Total'},
|
||||
];
|
||||
|
||||
// Build a CSV of a supply-plan shopping list (rows from planSupplyPurchase: {name, current, additional, target}),
|
||||
// reusing the inventory CSV writer so quoting/escaping stays identical.
|
||||
export function shoppingListCSV(rows) {
|
||||
return toCSV(SHOPPING_LIST_FIELDS, rows || []);
|
||||
}
|
||||
|
||||
// Filesystem-safe export filename derived from the inventory name (e.g. "Food Storage" -> "food-storage.csv").
|
||||
export function inventoryExportFilename(name, ext) {
|
||||
const base = (name || 'inventory').trim().toLowerCase()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import {inventoryExportFilename, toCSV} from "./inventoryExport";
|
||||
import {inventoryExportFilename, shoppingListCSV, toCSV} from "./inventoryExport";
|
||||
|
||||
const FIELDS = [
|
||||
{key: 'name', label: 'Name', type: 'text', order: 0},
|
||||
|
|
@ -48,6 +48,26 @@ describe('toCSV', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('shoppingListCSV', () => {
|
||||
test('emits the Item/Have/Buy/New Total columns for each plan row', () => {
|
||||
const rows = [
|
||||
{name: 'Canned Beans', current: 96, additional: 29, target: 125},
|
||||
{name: 'Mixed Vegetables', current: 180, additional: 55, target: 235},
|
||||
];
|
||||
expect(shoppingListCSV(rows)).toBe(
|
||||
'Item,Have,Buy,New Total\r\n' +
|
||||
'Canned Beans,96,29,125\r\n' +
|
||||
'Mixed Vegetables,180,55,235'
|
||||
);
|
||||
});
|
||||
|
||||
test('quotes a name containing a comma, and handles no rows', () => {
|
||||
expect(shoppingListCSV([{name: 'Beans, dried', current: 1, additional: 1, target: 2}]).split('\r\n')[1])
|
||||
.toBe('"Beans, dried",1,1,2');
|
||||
expect(shoppingListCSV([])).toBe('Item,Have,Buy,New Total');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inventoryExportFilename', () => {
|
||||
test('slugifies the inventory name with the given extension', () => {
|
||||
expect(inventoryExportFilename('Food Storage', 'csv')).toBe('food-storage.csv');
|
||||
|
|
|
|||
|
|
@ -52,6 +52,54 @@ export function findCountKey(fields) {
|
|||
return firstNumber ? firstNumber.key : null;
|
||||
}
|
||||
|
||||
// The field whose value names each item (for the shopping-list label): prefer a field keyed "name", else the first
|
||||
// text field, else none.
|
||||
export function findNameKey(fields) {
|
||||
const named = (fields || []).find(f => f.key === 'name');
|
||||
if (named) {
|
||||
return named.key;
|
||||
}
|
||||
const firstText = (fields || []).find(f => f.type === 'text');
|
||||
return firstText ? firstText.key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a proportional "buy more" shopping list to grow the whole inventory by `scale` (the target duration divided
|
||||
* by the current duration). Every item is scaled by the same factor so the mix stays balanced — matching the
|
||||
* user's mental model ("100 cans lasting 2 months, extend to 3 → buy 50 more").
|
||||
*
|
||||
* Each item's target count = ceil(currentCount × scale) — any fractional package is rounded UP to a whole one (a
|
||||
* bucket of wheat that extrapolates to 1.6 buckets means buying 1 more, not 0). `additional` is the positive
|
||||
* shortfall (items already at or above target are omitted). Items with no positive count have no basis to scale
|
||||
* from and are skipped. Returns `{rows, addedCalories}` where rows = [{name, current, target, additional}] sorted
|
||||
* by largest purchase first, and addedCalories is the calories the purchases would add (to project the new duration).
|
||||
*/
|
||||
export function planSupplyPurchase(items, {countKey, nameKey, caloriesKey, scale}) {
|
||||
const rows = [];
|
||||
let addedCalories = 0;
|
||||
if (scale > 1) {
|
||||
(items || []).forEach(item => {
|
||||
const current = countKey ? Number(item[countKey]) : NaN;
|
||||
if (!(current > 0)) {
|
||||
return;
|
||||
}
|
||||
const target = Math.ceil(current * scale);
|
||||
const additional = target - current;
|
||||
if (additional <= 0) {
|
||||
return;
|
||||
}
|
||||
const name = (nameKey && item[nameKey]) || '(unnamed)';
|
||||
rows.push({name, current, target, additional});
|
||||
const calories = caloriesKey ? Number(item[caloriesKey]) : 0;
|
||||
if (calories > 0) {
|
||||
addedCalories += calories * additional;
|
||||
}
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => b.additional - a.additional || a.name.localeCompare(b.name));
|
||||
return {rows, addedCalories};
|
||||
}
|
||||
|
||||
// Parse an item's field value into a finite number, or null when blank/non-numeric.
|
||||
function numericValue(item, key) {
|
||||
const raw = item[key];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import {
|
||||
defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey, groupFieldsOf, sortSummaryRows,
|
||||
summableFieldsOf, summarizeInventory,
|
||||
defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey, findNameKey, groupFieldsOf, planSupplyPurchase,
|
||||
sortSummaryRows, summableFieldsOf, summarizeInventory,
|
||||
} from "./summarize";
|
||||
|
||||
const FIELDS = [
|
||||
|
|
@ -45,6 +45,59 @@ describe('field-role helpers', () => {
|
|||
expect(findCaloriesKey(FIELDS)).toBe('calories');
|
||||
expect(findCountKey(FIELDS)).toBe('count');
|
||||
});
|
||||
|
||||
test('findNameKey prefers a "name" field, else the first text field', () => {
|
||||
expect(findNameKey(FIELDS)).toBe('name');
|
||||
expect(findNameKey([{key: 'label', type: 'text'}, {key: 'note', type: 'text'}])).toBe('label');
|
||||
expect(findNameKey([{key: 'qty', type: 'number'}])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planSupplyPurchase', () => {
|
||||
const opts = (scale) => ({countKey: 'count', nameKey: 'name', caloriesKey: 'calories', scale});
|
||||
|
||||
test('the canonical example: 100 cans lasting 2 months, extend to 3 → buy 50 more', () => {
|
||||
const items = [{name: 'Beans', count: '100', calories: '370'}];
|
||||
const {rows} = planSupplyPurchase(items, opts(3 / 2));
|
||||
expect(rows).toEqual([{name: 'Beans', current: 100, target: 150, additional: 50}]);
|
||||
});
|
||||
|
||||
test('rounds a split package UP to a whole one', () => {
|
||||
// One 50 lb bucket extrapolating to ~1.6 buckets → recommend buying 1 (not 0).
|
||||
const items = [{name: 'Wheat', count: '1', calories: '7500'}];
|
||||
expect(planSupplyPurchase(items, opts(1.6)).rows).toEqual(
|
||||
[{name: 'Wheat', current: 1, target: 2, additional: 1}]);
|
||||
// Even a small fraction rounds up.
|
||||
expect(planSupplyPurchase(items, opts(1.05)).rows[0].additional).toBe(1);
|
||||
});
|
||||
|
||||
test('scales every item proportionally (balanced restock) and reports added calories', () => {
|
||||
const items = [
|
||||
{name: 'Rice', count: '14', calories: '8040'},
|
||||
{name: 'Beans', count: '96', calories: '370'},
|
||||
{name: 'Salt', count: '6', calories: '0'}, // zero-calorie items still scale
|
||||
];
|
||||
const {rows, addedCalories} = planSupplyPurchase(items, opts(1.25));
|
||||
const byName = Object.fromEntries(rows.map(r => [r.name, r]));
|
||||
expect(byName.Rice.additional).toBe(4); // ceil(14×1.25)=18
|
||||
expect(byName.Beans.additional).toBe(24); // ceil(96×1.25)=120
|
||||
expect(byName.Salt.additional).toBe(2); // ceil(6×1.25)=8, still listed
|
||||
// Added calories ignore the zero-calorie salt: 8040×4 + 370×24.
|
||||
expect(addedCalories).toBe(8040 * 4 + 370 * 24);
|
||||
// Sorted by largest purchase first.
|
||||
expect(rows.map(r => r.name)).toEqual(['Beans', 'Rice', 'Salt']);
|
||||
});
|
||||
|
||||
test('a scale of 1 or less yields no purchases', () => {
|
||||
const items = [{name: 'Rice', count: '14', calories: '8040'}];
|
||||
expect(planSupplyPurchase(items, opts(1)).rows).toEqual([]);
|
||||
expect(planSupplyPurchase(items, opts(0.5)).rows).toEqual([]);
|
||||
});
|
||||
|
||||
test('items with no positive count are skipped (no basis to scale)', () => {
|
||||
const items = [{name: 'Mystery', count: '', calories: '500'}, {name: 'Rice', count: '0', calories: '8040'}];
|
||||
expect(planSupplyPurchase(items, opts(2)).rows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeInventory', () => {
|
||||
|
|
|
|||
|
|
@ -21,12 +21,12 @@ items:
|
|||
# --- Dairy ---
|
||||
- {id: 9, name: Nonfat Dry Milk, category: dairy, subcategory: dry milk, item_size: '64', item_size_unit: oz, calories: '6400'}
|
||||
- {id: 10, name: Evaporated Milk, category: dairy, subcategory: evaporated milk, item_size: '12', item_size_unit: oz, calories: '480'}
|
||||
- {id: 11, name: Velveeta Cheese, category: dairy, subcategory: cheese, item_size: '32', item_size_unit: oz, calories: '2560'}
|
||||
- {id: 11, name: Processed Cheese, category: dairy, subcategory: cheese, item_size: '32', item_size_unit: oz, calories: '2560'}
|
||||
|
||||
# --- Fats ---
|
||||
- {id: 12, name: Butter, category: fats, subcategory: butter, item_size: '1', item_size_unit: lb, calories: '3200'}
|
||||
- {id: 13, name: Extra-Virgin Olive Oil, category: fats, subcategory: oil, item_size: '50.7', item_size_unit: oz, calories: '12000'}
|
||||
- {id: 14, name: Shortening, category: fats, subcategory: shortening, item_size: '3', item_size_unit: lb, calories: '12000'}
|
||||
- {id: 14, name: Shortening, category: fats, subcategory: shortening, item_size: '3', item_size_unit: lb, calories: '12430'}
|
||||
- {id: 15, name: Peanut Butter, category: fats, subcategory: peanut butter, item_size: '18', item_size_unit: oz, calories: '3040'}
|
||||
|
||||
# --- Sugars ---
|
||||
|
|
@ -35,14 +35,14 @@ items:
|
|||
- {id: 18, name: Confectioners Sugar, category: sugars, subcategory: powdered sugar, item_size: '1', item_size_unit: lb, calories: '1800'}
|
||||
- {id: 19, name: Light Corn Syrup, category: sugars, subcategory: corn syrup, item_size: '16', item_size_unit: oz, calories: '1920'}
|
||||
- {id: 20, name: Maple Syrup, category: sugars, subcategory: syrup, item_size: '12.5', item_size_unit: oz, calories: '1200'}
|
||||
- {id: 21, name: Clover Honey, category: sugars, subcategory: honey, item_size: '16', item_size_unit: oz, calories: '1280'}
|
||||
- {id: 21, name: Honey, category: sugars, subcategory: honey, item_size: '16', item_size_unit: oz, calories: '1280'}
|
||||
- {id: 22, name: Cocoa Syrup, category: sugars, subcategory: syrup, item_size: '16', item_size_unit: oz, calories: '1200'}
|
||||
- {id: 23, name: Jelly or Preserves, category: sugars, subcategory: jelly, item_size: '16', item_size_unit: oz, calories: '1300'}
|
||||
|
||||
# --- Meats ---
|
||||
- {id: 24, name: Tuna in Oil, category: meats, subcategory: canned, item_size: '5', item_size_unit: oz, calories: '187'}
|
||||
- {id: 25, name: Canned Ham, category: meats, subcategory: canned, item_size: '16', item_size_unit: oz, calories: '800'}
|
||||
- {id: 26, name: Spam, category: meats, subcategory: canned, item_size: '12', item_size_unit: oz, calories: '1020'}
|
||||
- {id: 26, name: Canned Lunch Meat, category: meats, subcategory: canned, item_size: '12', item_size_unit: oz, calories: '1020'}
|
||||
- {id: 27, name: Vienna Sausage, category: meats, subcategory: canned, item_size: '5', item_size_unit: oz, calories: '375'}
|
||||
- {id: 28, name: Roast Beef, category: meats, subcategory: canned, item_size: '12', item_size_unit: oz, calories: '375'}
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ items:
|
|||
- {id: 29, name: Cream of Chicken Soup, category: meals, subcategory: canned, item_size: '10.75', item_size_unit: oz, calories: '300'}
|
||||
- {id: 30, name: Canned Macaroni and Cheese, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '480'}
|
||||
- {id: 31, name: Canned Pasta, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '500'}
|
||||
- {id: 32, name: Chunky Soup, category: meals, subcategory: canned, item_size: '18.8', item_size_unit: oz, calories: '500'}
|
||||
- {id: 32, name: Chunky Soup, category: meals, subcategory: canned, item_size: '18.8', item_size_unit: oz, calories: '360'}
|
||||
- {id: 33, name: Roast Beef Hash, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '780'}
|
||||
- {id: 34, name: Beef Stew, category: meals, subcategory: canned, item_size: '24', item_size_unit: oz, calories: '630'}
|
||||
- {id: 35, name: Chili with Beans, category: meals, subcategory: canned, item_size: '15', item_size_unit: oz, calories: '700'}
|
||||
|
|
|
|||
|
|
@ -184,7 +184,12 @@ class InventoriesConfig(MultiFileConfig):
|
|||
return
|
||||
for default in DEFAULT_INVENTORIES:
|
||||
try:
|
||||
self.create_inventory(default['name'], default['type'])
|
||||
inventory = self.create_inventory(default['name'], default['type'])
|
||||
# Optionally populate the example with seed items (e.g. the recommended one-year food supply).
|
||||
items_factory = default.get('items_factory')
|
||||
items = items_factory() if items_factory else None
|
||||
if items:
|
||||
self.save_inventory(inventory['slug'], dict(items=items))
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to seed default inventory {default}: {e}')
|
||||
|
||||
|
|
|
|||
|
|
@ -96,9 +96,63 @@ DEFAULT_TYPE = 'food'
|
|||
|
||||
INVENTORY_TYPES = tuple(DEFAULT_FIELD_SETS.keys())
|
||||
|
||||
# A brand-new install seeds one example inventory of each kind so the feature is discoverable.
|
||||
# Recommended package counts for a one-adult, one-year (~3,000 kcal/day) food supply, keyed by the shipped
|
||||
# food-catalog id (see catalog_defaults.yaml). Transcribed from the same "One-Year Emergency Food Supply for One
|
||||
# Adult" source list. `seed_defaults()` joins these counts with the catalog's per-package data to populate the
|
||||
# example "Food Storage" inventory on a fresh install (e.g. id 1 = white rice: 70 lb stored as 14 x 5 lb bags).
|
||||
RECOMMENDED_ONE_YEAR_FOOD_COUNTS = {
|
||||
# Grains
|
||||
1: 14, 2: 14, 3: 6, 4: 4, 5: 4, 6: 36, 7: 4, 8: 12,
|
||||
# Dairy
|
||||
9: 12, 10: 24, 11: 3,
|
||||
# Fats
|
||||
12: 12, 13: 5, 14: 2, 15: 12,
|
||||
# Sugars
|
||||
16: 5, 17: 12, 18: 12, 19: 6, 20: 6, 21: 9, 22: 8, 23: 12,
|
||||
# Meats
|
||||
24: 48, 25: 12, 26: 24, 27: 24, 28: 24,
|
||||
# Meals (prepared / canned)
|
||||
29: 12, 30: 24, 31: 24, 32: 24, 33: 24, 34: 18, 35: 24,
|
||||
# Legumes
|
||||
36: 96,
|
||||
# Vegetables
|
||||
37: 24, 38: 36, 39: 12, 40: 180, 41: 12,
|
||||
# Fruits
|
||||
42: 48,
|
||||
# Cooking ingredients
|
||||
43: 12, 44: 12, 45: 24, 46: 6, 47: 12, 48: 24, 49: 6, 50: 12, 51: 12, 52: 12,
|
||||
53: 2, 54: 2, 55: 2, 56: 1, 57: 2, 58: 1, 59: 48, 60: 48,
|
||||
}
|
||||
|
||||
# Per-package fields copied from a food-catalog entry onto a seeded inventory item (its `count` is added from
|
||||
# RECOMMENDED_ONE_YEAR_FOOD_COUNTS).
|
||||
_RECOMMENDED_ITEM_KEYS = ('name', 'category', 'subcategory', 'item_size', 'item_size_unit', 'calories')
|
||||
|
||||
|
||||
def recommended_food_storage_items() -> list:
|
||||
"""Build the example one-adult/one-year Food Storage items.
|
||||
|
||||
Joins the shipped food catalog (per-package nutrition in ``catalog_defaults.yaml``) with
|
||||
``RECOMMENDED_ONE_YEAR_FOOD_COUNTS`` (how many packages to store), so the per-package nutritional data keeps a
|
||||
single source of truth and this layer only adds quantities. Items are kept generic (no brand).
|
||||
"""
|
||||
from .catalog import load_catalog_defaults
|
||||
by_id = {e.get('id'): e for e in load_catalog_defaults()}
|
||||
items = []
|
||||
for catalog_id, count in RECOMMENDED_ONE_YEAR_FOOD_COUNTS.items():
|
||||
entry = by_id.get(catalog_id)
|
||||
if not entry:
|
||||
continue
|
||||
item = {key: entry.get(key, '') for key in _RECOMMENDED_ITEM_KEYS}
|
||||
item['count'] = str(count)
|
||||
items.append(item)
|
||||
return items
|
||||
|
||||
|
||||
# A brand-new install seeds one example inventory of each kind so the feature is discoverable. The food example
|
||||
# is populated with the recommended one-adult/one-year supply; `items_factory` is called lazily at seed time.
|
||||
DEFAULT_INVENTORIES = [
|
||||
dict(name='Food Storage', type='food'),
|
||||
dict(name='Food Storage', type='food', items_factory=recommended_food_storage_items),
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,10 +30,10 @@ def test_seed_catalog_defaults_and_tombstone(test_catalog_config):
|
|||
assert len(config.items) == seeded
|
||||
|
||||
# A user-deleted default is NOT re-added on the next seed (tombstoned via merged_default_ids).
|
||||
remaining = [i for i in config.items if i['name'] != 'Spam']
|
||||
remaining = [i for i in config.items if i['name'] != 'Canned Lunch Meat']
|
||||
catalog_module.save_catalog_items(remaining)
|
||||
catalog_module.seed_catalog_defaults(config)
|
||||
assert not any(i['name'] == 'Spam' for i in config.items)
|
||||
assert not any(i['name'] == 'Canned Lunch Meat' for i in config.items)
|
||||
|
||||
|
||||
def test_catalog_file_is_not_loaded_as_an_inventory(test_inventory_configs, test_catalog_config):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
from modules.inventory import common as inventory_common
|
||||
from modules.inventory.defaults import slugify, default_fields
|
||||
from modules.inventory.defaults import (slugify, default_fields, recommended_food_storage_items,
|
||||
RECOMMENDED_ONE_YEAR_FOOD_COUNTS)
|
||||
from modules.inventory.errors import InventoryConflict
|
||||
|
||||
|
||||
|
|
@ -114,6 +115,44 @@ def test_delete_inventory(food_inventory_factory, test_inventory_configs):
|
|||
assert config.all_inventories() == []
|
||||
|
||||
|
||||
def test_recommended_food_storage_items():
|
||||
"""The example supply joins the shipped food catalog (per-package nutrition) with the recommended counts."""
|
||||
items = recommended_food_storage_items()
|
||||
# One item per recommended count, all resolved against the catalog.
|
||||
assert len(items) == len(RECOMMENDED_ONE_YEAR_FOOD_COUNTS)
|
||||
# Kept generic: no brand names.
|
||||
assert all(not item.get('brand') for item in items)
|
||||
# Counts are positive strings; the per-package calorie basis comes from the catalog.
|
||||
assert all(item['count'] and int(item['count']) > 0 for item in items)
|
||||
rice = next(i for i in items if i['name'] == 'Long Grain White Rice')
|
||||
assert rice['count'] == '14'
|
||||
assert rice['calories'] == '8040'
|
||||
assert rice['item_size'] == '5' and rice['item_size_unit'] == 'lb'
|
||||
# Total stored calories approximate one adult-year at ~3,000 kcal/day.
|
||||
total = sum(int(i['calories']) * int(i['count']) for i in items if i.get('calories'))
|
||||
assert 1_000_000 < total < 1_300_000
|
||||
|
||||
|
||||
def test_seed_defaults_populates_food_storage(test_inventory_configs):
|
||||
config = test_inventory_configs
|
||||
config.seed_defaults()
|
||||
inventories = config.all_inventories()
|
||||
assert len(inventories) == 1
|
||||
inv = inventories[0]
|
||||
assert inv['name'] == 'Food Storage'
|
||||
assert inv['type'] == 'food'
|
||||
# Seeded with the full recommended one-adult/one-year supply, with server-assigned ids.
|
||||
assert len(inv['items']) == len(RECOMMENDED_ONE_YEAR_FOOD_COUNTS)
|
||||
assert all(isinstance(i['id'], int) for i in inv['items'])
|
||||
|
||||
|
||||
def test_seed_defaults_idempotent(test_inventory_configs):
|
||||
config = test_inventory_configs
|
||||
config.seed_defaults()
|
||||
config.seed_defaults()
|
||||
assert len(config.all_inventories()) == 1
|
||||
|
||||
|
||||
def test_config_round_trip(food_inventory_factory, test_inventory_configs):
|
||||
"""Saving to per-inventory YAML and re-importing preserves fields and items."""
|
||||
config = test_inventory_configs
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue