mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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>
84 lines
3.6 KiB
Python
84 lines
3.6 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'] != 'Spam']
|
|
catalog_module.save_catalog_items(remaining)
|
|
catalog_module.seed_catalog_defaults(config)
|
|
assert not any(i['name'] == 'Spam' 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
|