wrolpi/modules/inventory/test/test_migration.py
Roland 3d6aead072
Some checks failed
CodeQL / Analyze (push) Has been cancelled
Controller Tests / controller-tests (push) Has been cancelled
Jest Tests / jest-tests (push) Has been cancelled
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 17:14:54 -06:00

96 lines
3.7 KiB
Python

import pytest
import yaml
from modules.inventory import common as inventory_common
from modules.inventory.migrate import migrate_legacy_inventory, normalize_unit
def test_normalize_unit():
assert normalize_unit('pound') == 'lb'
assert normalize_unit('pounds') == 'lb'
assert normalize_unit('lbs') == 'lb'
# Already-valid mathjs units pass through unchanged.
assert normalize_unit('oz') == 'oz'
assert normalize_unit('gallon') == 'gallon'
assert normalize_unit(None) is None
def _write_legacy(config, inventories):
legacy_file = config.get_directory().parent / 'inventories.yaml'
legacy_file.parent.mkdir(parents=True, exist_ok=True)
with legacy_file.open('wt') as fh:
yaml.dump(dict(version=1, inventories=inventories), fh)
return legacy_file
def test_migrate_legacy_yaml(test_inventory_configs):
config = test_inventory_configs
legacy_file = _write_legacy(config, [
dict(name='Food Storage', created_at='2024-01-01', deleted_at=None, items=[
dict(id=1, inventory_id=1, brand='Ocean', name='Salt', item_size='25', unit='pound', count='8',
category='cooking ingredients', subcategory='salt', expiration_date='2030-01-01'),
dict(id=2, inventory_id=1, brand='deleted', name='Old', item_size='1', unit='oz', count='1',
deleted_at='2020-01-01'),
]),
])
assert migrate_legacy_inventory(config) is True
config.import_all()
inventories = config.all_inventories()
assert len(inventories) == 1
slug = inventories[0]['slug']
inventory = config.get_inventory(slug)
assert inventory['name'] == 'Food Storage'
assert inventory['type'] == 'food'
# The soft-deleted legacy item is dropped.
assert len(inventory['items']) == 1
item = inventory['items'][0]
assert item['name'] == 'Salt'
# Legacy pint unit 'pound' is normalized to mathjs 'lb'.
assert item['item_size_unit'] == 'lb'
# The legacy file is retired (renamed) and backed up so it is not migrated again.
assert not legacy_file.is_file()
assert legacy_file.with_suffix('.yaml.migrated').is_file()
def test_migrate_is_idempotent(test_inventory_configs):
config = test_inventory_configs
_write_legacy(config, [dict(name='Food', items=[])])
assert migrate_legacy_inventory(config) is True
config.import_all()
# Second run does nothing because per-inventory files now exist.
assert migrate_legacy_inventory(config) is False
config.import_all()
assert len(config.all_inventories()) == 1
def test_migrate_no_legacy_data(test_inventory_configs):
config = test_inventory_configs
assert migrate_legacy_inventory(config) is False
assert config.all_inventories() == []
def test_migrate_dedupes_same_named_inventories(test_inventory_configs):
"""Multiple inventories with the same name get distinct slugs (food, food-2, food-3) with no data loss."""
config = test_inventory_configs
_write_legacy(config, [
dict(name='Food', items=[]),
dict(name='Food', items=[]),
dict(name='Food', items=[]),
])
assert migrate_legacy_inventory(config) is True
config.import_all()
slugs = sorted(i['slug'] for i in config.all_inventories())
assert slugs == ['food', 'food-2', 'food-3']
assert len(config.all_inventories()) == 3 # none dropped
@pytest.mark.asyncio
async def test_import_seeds_defaults_when_empty(test_inventory_configs, async_client):
"""A fresh install with no legacy data seeds the default example inventory."""
config = test_inventory_configs
inventory_common.import_inventories_config()
inventories = config.all_inventories()
assert any(i['name'] == 'Food Storage' for i in inventories)