wrolpi/modules/inventory/test/test_catalog.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

84 lines
3.7 KiB
Python

import json
from http import HTTPStatus
import pytest
from modules.inventory import catalog as catalog_module
def test_load_catalog_defaults():
"""The shipped catalog_defaults.yaml loads and is non-trivial."""
defaults = catalog_module.load_catalog_defaults()
assert len(defaults) > 30
rice = next(d for d in defaults if d['name'] == 'Long Grain White Rice')
assert rice['category'] == 'grains'
assert rice['calories'] == '8040'
def test_seed_catalog_defaults_and_tombstone(test_catalog_config):
config = test_catalog_config
assert config.items == []
# Seeding loads all shipped defaults.
catalog_module.seed_catalog_defaults(config)
seeded = len(config.items)
assert seeded == len(catalog_module.load_catalog_defaults())
assert sorted(config.merged_default_ids) == sorted(d['id'] for d in catalog_module.load_catalog_defaults())
# Seeding again is idempotent (nothing re-added).
catalog_module.seed_catalog_defaults(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'] != 'Canned Lunch Meat']
catalog_module.save_catalog_items(remaining)
catalog_module.seed_catalog_defaults(config)
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):
"""catalog.yaml shares config/inventory/ but must never appear as an inventory."""
# Seed the catalog (writes config/inventory/catalog.yaml) and an inventory.
catalog_module.seed_catalog_defaults(test_catalog_config)
test_inventory_configs.create_inventory('Food Storage', 'food')
# Re-import the inventory directory; the catalog file must be excluded.
test_inventory_configs.import_all()
slugs = [i['slug'] for i in test_inventory_configs.all_inventories()]
assert 'catalog' not in slugs
assert slugs == ['food-storage']
def test_inventory_named_catalog_does_not_collide(test_inventory_configs):
"""A user inventory named 'Catalog' must not claim the reserved catalog slug."""
inv = test_inventory_configs.create_inventory('Catalog', 'food')
assert inv['slug'] != 'catalog'
def test_save_catalog_items_assigns_ids(test_catalog_config):
saved = catalog_module.save_catalog_items([
{'name': 'Test Beans', 'category': 'legumes', 'subcategory': 'dry beans',
'item_size': '25', 'item_size_unit': 'lb', 'calories': '39200'},
{'name': 'No Id Item', 'category': 'grains'},
])
assert [i['name'] for i in saved] == ['Test Beans', 'No Id Item']
assert all(isinstance(i['id'], int) for i in saved)
assert len({i['id'] for i in saved}) == 2
@pytest.mark.asyncio
async def test_catalog_api(test_catalog_config, async_client):
# Empty to start.
request, response = await async_client.get('/api/inventory/catalog')
assert response.status_code == HTTPStatus.OK
assert response.json['catalog'] == []
items = [{'name': 'White Rice', 'category': 'grains', 'subcategory': 'rice',
'item_size': '5', 'item_size_unit': 'lb', 'calories': '8040'}]
request, response = await async_client.put('/api/inventory/catalog', content=json.dumps(dict(items=items)))
assert response.status_code == HTTPStatus.OK, response.status_code
assert response.json['catalog'][0]['name'] == 'White Rice'
assert isinstance(response.json['catalog'][0]['id'], int)
# The static /catalog route is not shadowed by the dynamic /<slug> route.
request, response = await async_client.get('/api/inventory/catalog')
assert len(response.json['catalog']) == 1