wrolpi/modules/inventory/test/test_inventory.py

317 lines
13 KiB
Python

from decimal import Decimal
from itertools import zip_longest
from typing import List, Iterable
import pytest
import sqlalchemy
from pint import Quantity
from wrolpi.common import Base
from wrolpi.db import get_db_session
from wrolpi.test.common import PytestCase
from .. import init_inventory
from ..common import sum_by_key, get_inventory_by_category, get_inventory_by_subcategory, get_inventory_by_name, \
compact_unit, cleanup_quantity, save_inventories_config, import_inventories_config
from ..errors import NoInventories
from ..inventory import get_inventories, save_inventory, update_inventory, \
delete_inventory, get_categories, get_unit_registry
from ..models import Item, Inventory
UNIT_REGISTRY = get_unit_registry()
TEST_ITEMS_COLUMNS = (
'inventory_id',
'brand',
'name',
'item_size',
'unit',
'count',
'category',
'subcategory',
'deleted_at',
'serving',
'expiration_date',
'purchase_date',
)
TEST_ITEMS_TUPLES = [
(1, 'Wheaters', 'Red Wheat', 500, 'oz', 1, 'grains', 'wheat'),
(1, 'Wheaters', 'Red Wheat', 55, 'pounds', 2, 'grains', 'wheat'),
(1, 'Ricey', 'White Rice', 8, 'pounds', 1, 'grains', 'rice'),
(1, 'Chewy', 'Chicken Breast', 16, 'oz', 8, 'meats', 'canned'),
(1, 'Chewy', 'Beef', 16, 'oz', 12, 'meats', 'canned'),
(1, 'Vibrant', 'Peaches', 24, 'oz', 2, 'fruits', 'canned'),
(1, 'Vibrant', 'Pineapple', Decimal('22.3'), 'oz', 1, 'fruits', 'canned'),
# These are in the same category, but have conflicting unit types. (gallon vs pound)
(1, 'White', 'Vinegar', 1, 'gallon', 1, 'cooking ingredients', 'vinegar'),
(1, 'Salty', 'Salt', 1, 'pound', 1, 'cooking ingredients', 'salt'),
# This item is deleted, and should always be ignored.
(1, 'deleted', 'deleted', Decimal('1'), 'oz', 1, 'fruits', 'canned', '2020-01-01'),
]
TEST_ITEMS = [dict(zip(TEST_ITEMS_COLUMNS, i)) for i in TEST_ITEMS_TUPLES]
def extract_items(lst: List[Base], keys: Iterable[str]) -> List[Base]:
extracted = []
for i in lst:
extracted.append({j: getattr(i, j) for j in keys})
return extracted
class TestInventory(PytestCase):
@staticmethod
def prepare() -> None:
"""
This exists rather than setUp because the DB is wrapped.
"""
init_inventory(force=True)
with get_db_session(commit=True) as session:
for item in TEST_ITEMS:
item = Item(**item)
session.add(item)
def test_get_categories(self, test_session):
self.prepare()
categories = get_categories()
self.assertGreater(len(categories), 0)
def test_get_inventories(self, test_session):
self.prepare()
inventories = get_inventories()
dates = extract_items(inventories, {'created_at', 'deleted_at', 'viewed_at'})
self.assertItemsTruthyOrFalsey(dates, [{'created_at': True, 'deleted_at': False, 'viewed_at': False}])
self.assertEqual(inventories[0].id, 1)
self.assertEqual(inventories[0].name, 'Food Storage')
def test_inventory1(self, test_session):
self.prepare()
# Insert a new Inventory.
inventory = {
'name': 'New Inventory',
'viewed_at': 'asdf', # This should be ignored.
}
save_inventory(inventory)
with get_db_session() as session:
i1, i2 = session.query(Inventory).order_by(Inventory.name).all()
self.assertDictContains(i2, {'name': 'New Inventory', 'viewed_at': None})
# Inventories cannot share a name.
self.assertRaises(sqlalchemy.exc.IntegrityError, save_inventory, inventory)
# Insert a second inventory.
inventory['name'] = 'Super Inventory'
save_inventory(inventory)
# Cannot update the name to a name that is already used.
with get_db_session() as session:
i = session.query(Inventory).filter_by(name='Super Inventory').one()
inventory['name'] = 'New Inventory'
self.assertRaises(sqlalchemy.exc.IntegrityError, update_inventory, i.id, inventory)
# Add some items to "New Inventory"
with get_db_session(commit=True) as session:
before_item_count = session.query(Item).count()
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
session.add(Item(inventory_id=2, brand='a', name='b', item_size=45, unit='pounds', count=1))
# You can rename a inventory to a conflicting name, if the other inventory is marked as deleted.
with get_db_session(commit=True) as session:
delete_inventory(2)
# Check that the items from the deleted Inventory were not deleted, YET.
self.assertEqual(before_item_count + 4, session.query(Item).count())
i = session.query(Inventory).filter_by(name='Super Inventory').one()
inventory['name'] = 'New Inventory'
update_inventory(i.id, inventory)
# Check that the items from the deleted Inventory were really deleted.
self.assertEqual(before_item_count, session.query(Item).count())
def test_get_inventory_by_category(self, test_session):
self.prepare()
expected = [
dict(category='cooking ingredients', total_size=Decimal('1'), unit='gallon'),
dict(category='cooking ingredients', total_size=Decimal('1'), unit='pound'),
dict(category='fruits', total_size=Decimal('4.39375'), unit='pound'),
dict(category='grains', total_size=Decimal('149.25'), unit='pound'),
dict(category='meats', total_size=Decimal('20'), unit='pound'),
]
inventory = get_inventory_by_category(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'category inventory {idx} is not equal')
def test_get_inventory_by_subcategory(self, test_session):
self.prepare()
expected = [
dict(category='cooking ingredients', subcategory='salt', total_size=Decimal('1'), unit='pound'),
dict(category='cooking ingredients', subcategory='vinegar', total_size=Decimal('1'), unit='gallon'),
dict(category='fruits', subcategory='canned', total_size=Decimal('4.39375'), unit='pound'),
dict(category='grains', subcategory='rice', total_size=Decimal('8'), unit='pound'),
dict(category='grains', subcategory='wheat', total_size=Decimal('141.25'), unit='pound'),
dict(category='meats', subcategory='canned', total_size=Decimal('20'), unit='pound'),
]
inventory = get_inventory_by_subcategory(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'subcategory inventory {idx} is not equal')
def test_get_inventory_by_name(self, test_session):
self.prepare()
expected = [
dict(brand='Chewy', name='Beef', total_size=Decimal('12'), unit='pound'),
dict(brand='Chewy', name='Chicken Breast', total_size=Decimal('8'), unit='pound'),
dict(brand='Ricey', name='White Rice', total_size=Decimal('8'), unit='pound'),
dict(brand='Salty', name='Salt', total_size=Decimal('1'), unit='pound'),
dict(brand='Vibrant', name='Peaches', total_size=Decimal('3'), unit='pound'),
dict(brand='Vibrant', name='Pineapple', total_size=Decimal('1.39375'), unit='pound'),
dict(brand='Wheaters', name='Red Wheat', total_size=Decimal('141.25'), unit='pound'),
dict(brand='White', name='Vinegar', total_size=Decimal('1'), unit='gallon'),
]
inventory = get_inventory_by_name(1)
for idx, (i, j) in enumerate(zip_longest(inventory, expected)):
self.assertEqual(i, j, f'named inventory {idx} is not equal')
def quantity_to_string(quantity: Quantity) -> str:
quantity = cleanup_quantity(quantity)
num, (units,) = quantity.to_tuple()
(unit, _) = units
return f'{num} {unit}'
@pytest.mark.parametrize(
'quantity,expected',
[
(Decimal(5) * UNIT_REGISTRY.ounce, '5 ounce'),
(Decimal(16) * UNIT_REGISTRY.ounce, '1 pound'),
(Decimal(500) * UNIT_REGISTRY.pound, '500 pound'),
(Decimal(2000) * UNIT_REGISTRY.pound, '1 ton'),
(Decimal(128000) * UNIT_REGISTRY.ounce, '4 ton'),
]
)
def test_compact_unit(quantity, expected):
# Round the result, so we don't have to specify all those zeros for the test definition.
assert quantity_to_string(compact_unit(quantity)) == expected
pound, oz, gram, gallon = UNIT_REGISTRY('pound'), UNIT_REGISTRY('oz'), UNIT_REGISTRY('gram'), UNIT_REGISTRY('gallon')
pound, oz, gram, gallon = pound.units, oz.units, gram.units, gallon.units
mass = pound.dimensionality
length = gallon.dimensionality
@pytest.mark.parametrize(
'items,expected',
[
(
# No conversion is necessary.
[{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'}],
{(('grains',), oz.dimensionality): Decimal('1') * oz},
),
(
# The largest of the units is what is returned.
[
{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'},
{'category': 'grains', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'lbs'},
],
{(('grains',), pound.dimensionality): Decimal('1.0625') * pound},
),
(
# Items are summed by category.
[
{'category': 'grains', 'count': Decimal('8'), 'item_size': Decimal('24'), 'unit': 'oz'},
{'category': 'grains', 'count': Decimal('2'), 'item_size': Decimal('45'), 'unit': 'lbs'},
{'category': 'fruits', 'count': Decimal('4'), 'item_size': Decimal('1'), 'unit': 'gram'},
],
{
(('grains',), pound.dimensionality): Decimal('102') * pound,
(('fruits',), gram.dimensionality): Decimal('4') * gram,
},
),
(
[
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'),
'unit': 'gallon'},
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'),
'unit': 'quart'},
{'category': 'cooking ingredients', 'count': Decimal('1'), 'item_size': Decimal('1'), 'unit': 'oz'},
],
{
(('cooking ingredients',), gallon.dimensionality): Decimal('1.25') * gallon,
(('cooking ingredients',), oz.dimensionality): Decimal('1') * oz,
},
),
]
)
def test_sum_by_key(items, expected):
items = [Item(**i) for i in items]
assert sum_by_key(items, lambda i: (i.category,)) == expected
def test_no_inventories(async_client, test_session, test_directory):
"""Can't save empty inventories."""
try:
save_inventories_config()
except NoInventories:
pass
@pytest.mark.asyncio
async def test_inventories_config(await_switches, test_session, test_directory, init_test_inventory):
for item in TEST_ITEMS:
item = Item(**item)
test_session.add(item)
test_session.commit()
# Save the Inventories/Items that were created above.
save_inventories_config()
await await_switches()
# Clear out the DB so the import will be tested
test_session.query(Item).delete()
test_session.query(Inventory).delete()
test_session.commit()
import_inventories_config()
inventories = get_inventories()
assert len(inventories) == 1
# ID has increased because we did not reset the sequence when deleting from the table.
assert inventories[0].id == 2
assert inventories[0].name == 'Food Storage'
# All items in the DB match those in the test list, except for the "deleted" item.
non_deleted_items = [i for i in inventories[0].items if i.deleted_at is None]
assert len(non_deleted_items) == len(TEST_ITEMS) - 1
test_items = {(i['name'], i['brand'], i['count']) for i in TEST_ITEMS[:-1]}
db_items = {(i.name, i.brand, i.count) for i in non_deleted_items}
assert test_items == db_items
# An extra Inventory with items should be deleted from the DB.
inventory = Inventory(name='delete me')
test_session.add(inventory)
inventory.flush()
test_session.add(Item(inventory_id=inventory.id))
test_session.commit()
assert test_session.query(Inventory).count() == 2
assert {i for i, in test_session.query(Inventory.name)} == {'Food Storage', 'delete me'}
import_inventories_config()
assert test_session.query(Inventory).count() == 1
assert test_session.query(Inventory).one().name == 'Food Storage'