2021-11-01 16:42:52 -06:00
|
|
|
import pytest
|
|
|
|
|
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
from modules.inventory import common as inventory_common
|
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>
2026-06-19 12:39:58 -06:00
|
|
|
from modules.inventory.defaults import (slugify, default_fields, recommended_food_storage_items,
|
|
|
|
|
RECOMMENDED_ONE_YEAR_FOOD_COUNTS)
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
from modules.inventory.errors import InventoryConflict
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
def test_slugify():
|
|
|
|
|
assert slugify('Food Storage') == 'food-storage'
|
|
|
|
|
assert slugify(' Tools & Stuff!! ') == 'tools-stuff'
|
|
|
|
|
assert slugify('') == 'inventory'
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
def test_default_fields():
|
|
|
|
|
food = default_fields('food')
|
|
|
|
|
assert [f['key'] for f in food][:2] == ['brand', 'name']
|
|
|
|
|
# Unknown type falls back to food.
|
|
|
|
|
assert default_fields('nonexistent')[0]['key'] == 'brand'
|
|
|
|
|
# The size field defaults to a mathjs-valid unit (lb, not pound).
|
|
|
|
|
size = next(f for f in food if f['key'] == 'item_size')
|
|
|
|
|
assert size['type'] == 'quantity'
|
|
|
|
|
assert size['unit'] == 'lb'
|
2021-11-01 16:42:52 -06:00
|
|
|
|
|
|
|
|
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
def test_create_and_list_inventory(test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
inventory = config.create_inventory('Food Storage', 'food')
|
|
|
|
|
assert inventory['slug'] == 'food-storage'
|
2021-11-01 16:42:52 -06:00
|
|
|
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
inventories = config.all_inventories()
|
2022-02-16 21:57:27 -07:00
|
|
|
assert len(inventories) == 1
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
assert inventories[0]['name'] == 'Food Storage'
|
|
|
|
|
assert inventories[0]['type'] == 'food'
|
|
|
|
|
# all_inventories returns full inventories (fields + items), not summaries.
|
|
|
|
|
assert [f['key'] for f in inventories[0]['fields']] == [f['key'] for f in default_fields('food')]
|
|
|
|
|
assert inventories[0]['items'] == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_inventory_unique_slug(test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
first = config.create_inventory('Tools', 'tool')
|
|
|
|
|
second = config.create_inventory('Tools', 'tool')
|
|
|
|
|
assert first['slug'] == 'tools'
|
|
|
|
|
assert second['slug'] == 'tools-2'
|
|
|
|
|
assert len(config.all_inventories()) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_inventory_items(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory()
|
|
|
|
|
|
|
|
|
|
# Whole-inventory save: add two items. Ids are assigned server-side.
|
|
|
|
|
saved = config.save_inventory(slug, dict(items=[
|
|
|
|
|
dict(brand='Ocean', name='Salt', item_size='25', item_size_unit='lb', count='8', bogus='nope'),
|
|
|
|
|
dict(name='Rice', count='4'),
|
|
|
|
|
]))
|
|
|
|
|
items = saved['items']
|
|
|
|
|
assert {i['name'] for i in items} == {'Salt', 'Rice'}
|
|
|
|
|
assert all(isinstance(i['id'], int) for i in items)
|
|
|
|
|
assert len({i['id'] for i in items}) == 2
|
|
|
|
|
# Unknown keys not in the field schema are dropped.
|
|
|
|
|
assert all('bogus' not in i for i in items)
|
|
|
|
|
|
|
|
|
|
# Saving again with one item removed persists the deletion.
|
|
|
|
|
keep = [i for i in items if i['name'] == 'Rice']
|
|
|
|
|
saved = config.save_inventory(slug, dict(items=keep))
|
|
|
|
|
assert [i['name'] for i in saved['items']] == ['Rice']
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 17:59:46 -04:00
|
|
|
def test_save_inventory_strips_whitespace(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
"""Leading/trailing whitespace in string field values is stripped on save (categories, names, etc.)."""
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory()
|
|
|
|
|
saved = config.save_inventory(slug, dict(items=[
|
|
|
|
|
dict(name=' Rice ', category=' grains ', count='4'),
|
|
|
|
|
]))
|
|
|
|
|
item = saved['items'][0]
|
|
|
|
|
assert item['name'] == 'Rice'
|
|
|
|
|
assert item['category'] == 'grains'
|
|
|
|
|
assert item['count'] == '4'
|
|
|
|
|
|
|
|
|
|
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
def test_save_inventory_fields(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory()
|
|
|
|
|
new_fields = [
|
|
|
|
|
dict(key='name', label='Name', type='text'),
|
|
|
|
|
dict(key='location', label='Location', type='location'),
|
|
|
|
|
]
|
|
|
|
|
saved = config.save_inventory(slug, dict(fields=new_fields))
|
|
|
|
|
assert [f['key'] for f in saved['fields']] == ['name', 'location']
|
|
|
|
|
# Order is normalized to list order.
|
|
|
|
|
assert [f['order'] for f in saved['fields']] == [0, 1]
|
|
|
|
|
|
|
|
|
|
|
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>
2026-06-19 14:36:26 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
def test_save_inventory_rename_keeps_slug(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory(name='Food Storage')
|
|
|
|
|
saved = config.save_inventory(slug, dict(name='Pantry'))
|
|
|
|
|
# Slug is a stable id; only the display name changes.
|
|
|
|
|
assert saved['slug'] == slug
|
|
|
|
|
assert saved['name'] == 'Pantry'
|
|
|
|
|
assert config.get_file(slug).is_file()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_inventory_version_conflict(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory()
|
|
|
|
|
current = config.get_inventory(slug)
|
|
|
|
|
version = current['version']
|
|
|
|
|
|
|
|
|
|
# A save with the matching version succeeds and bumps the version.
|
|
|
|
|
saved = config.save_inventory(slug, dict(name='First'), expected_version=version)
|
|
|
|
|
assert saved['version'] == version + 1
|
|
|
|
|
|
|
|
|
|
# A stale client (still on the old version) is rejected rather than clobbering.
|
|
|
|
|
with pytest.raises(InventoryConflict):
|
|
|
|
|
config.save_inventory(slug, dict(name='Stale'), expected_version=version)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_delete_inventory(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory()
|
|
|
|
|
assert config.get_file(slug).is_file()
|
|
|
|
|
config.delete_inventory(slug)
|
|
|
|
|
assert not config.get_file(slug).is_file()
|
|
|
|
|
assert config.all_inventories() == []
|
|
|
|
|
|
|
|
|
|
|
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>
2026-06-19 12:39:58 -06:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-19 16:13:56 -06:00
|
|
|
def _write_backup(config, slug, date, items, name='Food Storage'):
|
|
|
|
|
"""Write a dated backup file for an inventory (as the daily save would)."""
|
|
|
|
|
from wrolpi.common import write_config_data
|
|
|
|
|
backup = config._get_backup_file(slug, date)
|
|
|
|
|
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
write_config_data(dict(version=9, slug=slug, name=name, type='food',
|
|
|
|
|
fields=default_fields('food'), items=items), backup)
|
|
|
|
|
return backup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_restore_overwrite_replaces_inventory(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory(items=[dict(id=1, name='Rice', count='4'), dict(id=2, name='Beans', count='2')])
|
|
|
|
|
_write_backup(config, slug, '20260101', name='Old Name',
|
|
|
|
|
items=[dict(id=1, name='Rice', count='99'), dict(id=3, name='Oats', count='5')])
|
|
|
|
|
|
|
|
|
|
preview = config.preview_restore(slug, '20260101', 'overwrite')
|
|
|
|
|
assert {i['id'] for i in preview['add']} == {3} # Oats only in the backup
|
|
|
|
|
assert {i['id'] for i in preview['remove']} == {2} # Beans only in the current inventory
|
|
|
|
|
assert preview['unchanged'] == 1 # Rice (id 1) is in both
|
|
|
|
|
|
|
|
|
|
restored = config.apply_restore(slug, '20260101', 'overwrite')
|
|
|
|
|
assert restored['name'] == 'Old Name'
|
|
|
|
|
assert {i['name'] for i in restored['items']} == {'Rice', 'Oats'}
|
|
|
|
|
# Overwrite takes the backup's value for the shared item.
|
|
|
|
|
assert next(i for i in restored['items'] if i['name'] == 'Rice')['count'] == '99'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_restore_merge_unions_items_by_id(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory(items=[dict(id=1, name='Rice', count='4')])
|
|
|
|
|
_write_backup(config, slug, '20260101',
|
|
|
|
|
items=[dict(id=1, name='Rice', count='99'), dict(id=2, name='Beans', count='2')])
|
|
|
|
|
|
|
|
|
|
preview = config.preview_restore(slug, '20260101', 'merge')
|
|
|
|
|
assert {i['id'] for i in preview['add']} == {2} # only Beans is new; id 1 already present
|
|
|
|
|
assert preview['remove'] == []
|
|
|
|
|
|
|
|
|
|
restored = config.apply_restore(slug, '20260101', 'merge')
|
|
|
|
|
assert {i['name'] for i in restored['items']} == {'Rice', 'Beans'}
|
|
|
|
|
# Merge keeps the existing item untouched (does NOT take the backup's count=99 for id 1).
|
|
|
|
|
assert next(i for i in restored['items'] if i['name'] == 'Rice')['count'] == '4'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_restore_rejects_bad_mode_and_missing_backup(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
from wrolpi.errors import ValidationError
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory()
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
config.preview_restore(slug, '20260101', 'bogus')
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
config.preview_restore(slug, '20260101', 'overwrite') # no such backup file
|
|
|
|
|
# A non-YYYYMMDD backup_date must be rejected (path-traversal guard), not used to build a path.
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
config.preview_restore(slug, '../../../../etc/passwd', 'overwrite')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_restore_overwrite_rejects_corrupt_fields(food_inventory_factory, test_inventory_configs):
|
|
|
|
|
"""A backup with a malformed field (no 'key') is rejected with a 400, not a 500 inside the save lock."""
|
|
|
|
|
from wrolpi.common import write_config_data
|
|
|
|
|
from wrolpi.errors import ValidationError
|
|
|
|
|
config = test_inventory_configs
|
|
|
|
|
slug = food_inventory_factory(items=[dict(id=1, name='Rice', count='4')])
|
|
|
|
|
backup = config._get_backup_file(slug, '20260101')
|
|
|
|
|
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
write_config_data(dict(version=9, slug=slug, name='Food Storage', type='food',
|
|
|
|
|
fields=[dict(label='Broken', type='text')], # missing 'key'
|
|
|
|
|
items=[]), backup)
|
|
|
|
|
with pytest.raises(ValidationError):
|
|
|
|
|
config.apply_restore(slug, '20260101', 'overwrite')
|
|
|
|
|
|
|
|
|
|
|
Rebuild Inventory as a config-only system with food catalog and calculators
Replace the DB-backed inventory module with a config-only design and build out
food-storage tooling around it.
Inventory (config-only, no database)
- Each inventory is a YAML file at config/inventory/<slug>.yaml, loaded once
into memory and the single source of truth. New reusable MultiFileConfig
(wrolpi/common.py) manages a directory of per-entity files.
- Inventory types (food/fuel/tool) seed a customizable field schema
(text/number/quantity/date/select/location/calories). Unit math/aggregation
moved to the frontend (mathjs); Pint dropped from the backend.
- Minimal API: GET / (all inventories full), POST /, PUT /<slug> (whole-
inventory save with version-based optimistic concurrency; 404 for missing),
DELETE. Legacy inventories.yaml / old tables migrate to per-inventory files
(pound->lb), and an alembic migration drops the tables.
Frontend
- New app/src/components/inventory/ package: spreadsheet-style table with
Tab/Enter keyboard entry, sortable columns, expired-row highlighting,
customizable field editor, and pooled cross-inventory location autocomplete.
- A single useInventories store loads everything once and persists whole
inventories optimistically; list/items/summary/locations derive client-side.
- Summary groups items (default by Category), totals size x count, shows a
calories column, and embeds a ration estimate (how long the stock lasts).
Food catalog
- Shared, editable catalog (config/inventory/catalog.yaml) seeded from
catalog_defaults.yaml (~60 generic items, total calories per package) with
merge-keep-edits + tombstones. GET/PUT /api/inventory/catalog, a management
modal, and entry-row pre-fill of category/size/calories from a name match.
Calculators
- Generic Food Storage planner (like the Water calculator): household +
editable per-person/year amounts -> total to store over a chosen duration,
with women/children weighted by DGA calorie shares and an imperial/metric
toggle.
Also fixes ConfigFile.save() to accept nested file_names. Backend pytest and
frontend jest suites updated/added throughout; alembic check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:20:10 -06:00
|
|
|
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
|
|
|
|
|
slug = food_inventory_factory(name='Food Storage', items=[
|
|
|
|
|
dict(brand='Ocean', name='Salt', item_size='25', item_size_unit='lb', count='8'),
|
|
|
|
|
dict(brand='Ricey', name='Rice', item_size='10', item_size_unit='lb', count='4'),
|
|
|
|
|
])
|
|
|
|
|
assert config.get_file(slug).is_file()
|
|
|
|
|
|
|
|
|
|
# A fresh config instance re-reads the directory from disk.
|
|
|
|
|
fresh = inventory_common.InventoriesConfig()
|
|
|
|
|
fresh.initialize()
|
|
|
|
|
restored = fresh.get_inventory(slug)
|
|
|
|
|
assert restored is not None
|
|
|
|
|
assert restored['name'] == 'Food Storage'
|
|
|
|
|
assert {i['name'] for i in restored['items']} == {'Salt', 'Rice'}
|
|
|
|
|
assert [f['key'] for f in restored['fields']] == [f['key'] for f in default_fields('food')]
|