wrolpi/modules/inventory/test/test_inventory.py
Roland ee0b827c71
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:40:30 -06:00

172 lines
7.2 KiB
Python

import pytest
from modules.inventory import common as inventory_common
from modules.inventory.defaults import (slugify, default_fields, recommended_food_storage_items,
RECOMMENDED_ONE_YEAR_FOOD_COUNTS)
from modules.inventory.errors import InventoryConflict
def test_slugify():
assert slugify('Food Storage') == 'food-storage'
assert slugify(' Tools & Stuff!! ') == 'tools-stuff'
assert slugify('') == 'inventory'
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'
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'
inventories = config.all_inventories()
assert len(inventories) == 1
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_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]
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() == []
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
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')]