FunkyJuiceRecipes/tests/test_calculations.py

159 lines
4.8 KiB
Python
Raw Normal View History

feat(db,models,dao,logic): nicotine inventory + flavor snapshots; drop legacy nic_pct; migrations, tests • Migrations ◦ Create nicotine table with NOCASE strings, base PG/VG check, strength_mg_per_ml (>0), soft-delete, and optional inventory metadata (bottle_size_ml, cost, purchase_date). ◦ Alter recipe: add nic_target_mg_per_ml (NOT NULL), nic_strength_mg_per_ml (NOT NULL), nicotine_id (FK ON DELETE SET NULL); keep nic_base. Drop legacy nic_pct via rebuild migration. ◦ Alter flavor: add bottle_size_ml, cost, purchase_date (nullable). ◦ Alter recipe_flavor: add flavor snapshot columns (flavor_name_snapshot, flavor_company_snapshot NOCASE, flavor_base_snapshot PG/VG CHECK). ◦ Ensure migration order runs “_created” before ALTERs. • Models ◦ Add Nicotine model. ◦ Update Recipe with nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, optional nicotine FK. ◦ Update Flavor with optional inventory metadata. ◦ Update RecipeFlavor with snapshot fields and unique index remains. • DAOs ◦ Add NicotineRepository (CRUD, list with soft-delete filters; hard_delete_when_safe blocked when referenced by recipes). ◦ Update RecipeRepository: ▪ create/update accept nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, optional nicotine_id. ▪ If nicotine_id provided, snapshot strength/base from inventory unless overridden. ▪ replace_ingredients uses diff-based updates and populates flavor snapshots on insert. ◦ Update FlavorRepository to edit inventory metadata; soft/undelete; hard delete only when safe. • DB init ◦ Bind Peewee models to runtime DB; tweak migration ordering logic. • Logic ◦ Add calculation engine (compute_breakdown, breakdown_for_view) supporting target nicotine mg/mL and stock strength mg/mL; rounding policy for display. • Tests ◦ Update/init DB tests to include nicotine table and flexible migration counts. ◦ Add calculation tests (120 mL example, nic in VG, 50 mg/mL stock, validations). ◦ Add DAO tests including flavor snapshots stability and replace_ingredients diff behavior. ◦ All tests passing: 15 passed. • Docs ◦ Update SPECS and MILESTONES; strike through Milestones 3 and 4. BREAKING CHANGE: remove Recipe.nic_pct field and any compatibility code. API changes in RecipeRepository.create/update now require mg/mL fields and use snapshot+FK model for nicotine.
2025-12-19 01:55:22 +00:00
from __future__ import annotations
import math
import pytest
from funkyjuicerecipes.logic.calculations import (
compute_breakdown,
breakdown_for_view,
)
def test_readme_style_example_120ml_nic_in_pg():
bd = compute_breakdown(
size_ml=120,
base_pg_pct=30.0,
base_vg_pct=70.0,
nic_target_mg_per_ml=3.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[
{"name": "Vanilla", "base": "PG", "pct": 8.0},
("Strawberry", "VG", 4.0),
],
)
# Percentages
assert bd.pg_pct == pytest.approx(19.0)
assert bd.vg_pct == pytest.approx(66.0)
assert bd.nic_pct == pytest.approx(3.0)
# mL calculations
assert bd.pg_ml == pytest.approx(22.8)
assert bd.vg_ml == pytest.approx(79.2)
assert bd.nic_ml == pytest.approx(3.6)
names = [f.name for f in bd.flavors]
assert names == ["Vanilla", "Strawberry"]
amounts = {f.name: f.ml for f in bd.flavors}
assert amounts["Vanilla"] == pytest.approx(9.6)
assert amounts["Strawberry"] == pytest.approx(4.8)
assert bd.flavor_prep_ml == pytest.approx(14.4)
# Rounded view (1 decimal) should match obvious roundings
v = breakdown_for_view(bd)
assert v.pg_ml == pytest.approx(22.8)
assert v.vg_ml == pytest.approx(79.2)
assert v.nic_ml == pytest.approx(3.6)
assert {f.name: f.ml for f in v.flavors}["Vanilla"] == pytest.approx(9.6)
assert {f.name: f.ml for f in v.flavors}["Strawberry"] == pytest.approx(4.8)
assert v.flavor_prep_ml == pytest.approx(14.4)
def test_nic_in_vg_changes_pg_vg_split():
bd = compute_breakdown(
size_ml=120,
base_pg_pct=30.0,
base_vg_pct=70.0,
nic_target_mg_per_ml=3.0,
nic_strength_mg_per_ml=100.0,
nic_base="VG",
flavors=[
{"name": "Vanilla", "base": "PG", "pct": 8.0},
("Strawberry", "VG", 4.0),
],
)
assert bd.pg_pct == pytest.approx(22.0)
assert bd.vg_pct == pytest.approx(63.0)
assert bd.pg_ml == pytest.approx(26.4)
assert bd.vg_ml == pytest.approx(75.6)
@pytest.mark.parametrize(
"base_pg,base_vg",
[
(70.0, 30.0),
(70.0000001, 29.9999999), # tolerance
],
)
def test_base_pg_vg_sum_to_100_with_tolerance(base_pg, base_vg):
bd = compute_breakdown(
size_ml=60,
base_pg_pct=base_pg,
base_vg_pct=base_vg,
nic_target_mg_per_ml=0.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[],
)
assert bd.pg_pct + bd.vg_pct + bd.nic_pct + sum(f.pct for f in bd.flavors) == pytest.approx(100.0)
def test_excessive_flavor_or_negative_pg_raises():
# This combination would push PG negative (base PG 10, nic 10 PG, flavor PG 5 => PG becomes -5)
with pytest.raises(ValueError):
compute_breakdown(
size_ml=30,
base_pg_pct=10.0,
base_vg_pct=90.0,
nic_target_mg_per_ml=10.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[("Whatever", "PG", 5.0)],
)
# Flavors sum > 100 should also fail via negative VG/PG after deduction
with pytest.raises(ValueError):
compute_breakdown(
size_ml=30,
base_pg_pct=50.0,
base_vg_pct=50.0,
nic_target_mg_per_ml=0.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[("A", "PG", 60.0), ("B", "VG", 50.0)],
)
def test_input_variants_tuple_and_mapping_supported():
bd = compute_breakdown(
size_ml=10,
base_pg_pct=50.0,
base_vg_pct=50.0,
nic_target_mg_per_ml=0.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[
{"name": "X", "base": "PG", "pct": 5.0},
("Y", "VG", 5.0),
],
)
def test_50mg_stock_requires_more_nic_volume_and_changes_pg_vg_split():
# With 50 mg/mL stock, to reach 3 mg/mL target we need 6% nic volume.
bd = compute_breakdown(
size_ml=120,
base_pg_pct=30.0,
base_vg_pct=70.0,
nic_target_mg_per_ml=3.0,
nic_strength_mg_per_ml=50.0,
nic_base="PG",
flavors=[
{"name": "Vanilla", "base": "PG", "pct": 8.0},
("Strawberry", "VG", 4.0),
],
)
# Now nic_pct (volume) is 6%
assert bd.nic_pct == pytest.approx(6.0)
# PG is further reduced by extra 3% vs the 100 mg/mL case
# Base pg 30 - flavor PG 8 - nic 6 = 16
assert bd.pg_pct == pytest.approx(16.0)
# VG remains 70 - flavor VG 4 = 66
assert bd.vg_pct == pytest.approx(66.0)
# mL
assert bd.nic_ml == pytest.approx(7.2) # 6% of 120
assert bd.pg_ml == pytest.approx(19.2) # 16% of 120
assert bd.vg_ml == pytest.approx(79.2) # 66% of 120
assert {f.name for f in bd.flavors} == {"Vanilla", "Strawberry"}