wrolpi/modules/inventory/test/test_inventory.py

278 lines
13 KiB
Python
Raw Normal View History

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
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'
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'
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'
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']
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
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')]