From c8655e24e445548bbac57d31e4f548f13069e995 Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Thu, 18 Dec 2025 19:55:22 -0600 Subject: [PATCH] =?UTF-8?q?feat(db,models,dao,logic):=20nicotine=20invento?= =?UTF-8?q?ry=20+=20flavor=20snapshots;=20drop=20legacy=20nic=5Fpct;=20mig?= =?UTF-8?q?rations,=20tests=20=E2=80=A2=20Migrations=20=09=E2=97=A6=20Crea?= =?UTF-8?q?te=20nicotine=20table=20with=20NOCASE=20strings,=20base=20PG/VG?= =?UTF-8?q?=20check,=20strength=5Fmg=5Fper=5Fml=20(>0),=20soft-delete,=20a?= =?UTF-8?q?nd=20optional=20inventory=20metadata=20(bottle=5Fsize=5Fml,=20c?= =?UTF-8?q?ost,=20purchase=5Fdate).=20=09=E2=97=A6=20Alter=20recipe:=20add?= =?UTF-8?q?=20nic=5Ftarget=5Fmg=5Fper=5Fml=20(NOT=20NULL),=20nic=5Fstrengt?= =?UTF-8?q?h=5Fmg=5Fper=5Fml=20(NOT=20NULL),=20nicotine=5Fid=20(FK=20ON=20?= =?UTF-8?q?DELETE=20SET=20NULL);=20keep=20nic=5Fbase.=20Drop=20legacy=20ni?= =?UTF-8?q?c=5Fpct=20via=20rebuild=20migration.=20=09=E2=97=A6=20Alter=20f?= =?UTF-8?q?lavor:=20add=20bottle=5Fsize=5Fml,=20cost,=20purchase=5Fdate=20?= =?UTF-8?q?(nullable).=20=09=E2=97=A6=20Alter=20recipe=5Fflavor:=20add=20f?= =?UTF-8?q?lavor=20snapshot=20columns=20(flavor=5Fname=5Fsnapshot,=20flavo?= =?UTF-8?q?r=5Fcompany=5Fsnapshot=20NOCASE,=20flavor=5Fbase=5Fsnapshot=20P?= =?UTF-8?q?G/VG=20CHECK).=20=09=E2=97=A6=20Ensure=20migration=20order=20ru?= =?UTF-8?q?ns=20=E2=80=9C=5Fcreated=E2=80=9D=20before=20ALTERs.=20?= =?UTF-8?q?=E2=80=A2=20Models=20=09=E2=97=A6=20Add=20Nicotine=20model.=20?= =?UTF-8?q?=09=E2=97=A6=20Update=20Recipe=20with=20nic=5Ftarget=5Fmg=5Fper?= =?UTF-8?q?=5Fml,=20nic=5Fstrength=5Fmg=5Fper=5Fml,=20nic=5Fbase,=20option?= =?UTF-8?q?al=20nicotine=20FK.=20=09=E2=97=A6=20Update=20Flavor=20with=20o?= =?UTF-8?q?ptional=20inventory=20metadata.=20=09=E2=97=A6=20Update=20Recip?= =?UTF-8?q?eFlavor=20with=20snapshot=20fields=20and=20unique=20index=20rem?= =?UTF-8?q?ains.=20=E2=80=A2=20DAOs=20=09=E2=97=A6=20Add=20NicotineReposit?= =?UTF-8?q?ory=20(CRUD,=20list=20with=20soft-delete=20filters;=20hard=5Fde?= =?UTF-8?q?lete=5Fwhen=5Fsafe=20blocked=20when=20referenced=20by=20recipes?= =?UTF-8?q?).=20=09=E2=97=A6=20Update=20RecipeRepository:=20=09=09?= =?UTF-8?q?=E2=96=AA=20create/update=20accept=20nic=5Ftarget=5Fmg=5Fper=5F?= =?UTF-8?q?ml,=20nic=5Fstrength=5Fmg=5Fper=5Fml,=20nic=5Fbase,=20optional?= =?UTF-8?q?=20nicotine=5Fid.=20=09=09=E2=96=AA=20If=20nicotine=5Fid=20prov?= =?UTF-8?q?ided,=20snapshot=20strength/base=20from=20inventory=20unless=20?= =?UTF-8?q?overridden.=20=09=09=E2=96=AA=20replace=5Fingredients=20uses=20?= =?UTF-8?q?diff-based=20updates=20and=20populates=20flavor=20snapshots=20o?= =?UTF-8?q?n=20insert.=20=09=E2=97=A6=20Update=20FlavorRepository=20to=20e?= =?UTF-8?q?dit=20inventory=20metadata;=20soft/undelete;=20hard=20delete=20?= =?UTF-8?q?only=20when=20safe.=20=E2=80=A2=20DB=20init=20=09=E2=97=A6=20Bi?= =?UTF-8?q?nd=20Peewee=20models=20to=20runtime=20DB;=20tweak=20migration?= =?UTF-8?q?=20ordering=20logic.=20=E2=80=A2=20Logic=20=09=E2=97=A6=20Add?= =?UTF-8?q?=20calculation=20engine=20(compute=5Fbreakdown,=20breakdown=5Ff?= =?UTF-8?q?or=5Fview)=20supporting=20target=20nicotine=20mg/mL=20and=20sto?= =?UTF-8?q?ck=20strength=20mg/mL;=20rounding=20policy=20for=20display.=20?= =?UTF-8?q?=E2=80=A2=20Tests=20=09=E2=97=A6=20Update/init=20DB=20tests=20t?= =?UTF-8?q?o=20include=20nicotine=20table=20and=20flexible=20migration=20c?= =?UTF-8?q?ounts.=20=09=E2=97=A6=20Add=20calculation=20tests=20(120=20mL?= =?UTF-8?q?=20example,=20nic=20in=20VG,=2050=20mg/mL=20stock,=20validation?= =?UTF-8?q?s).=20=09=E2=97=A6=20Add=20DAO=20tests=20including=20flavor=20s?= =?UTF-8?q?napshots=20stability=20and=20replace=5Fingredients=20diff=20beh?= =?UTF-8?q?avior.=20=09=E2=97=A6=20All=20tests=20passing:=2015=20passed.?= =?UTF-8?q?=20=E2=80=A2=20Docs=20=09=E2=97=A6=20Update=20SPECS=20and=20MIL?= =?UTF-8?q?ESTONES;=20strike=20through=20Milestones=203=20and=204.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- MILESTONES.md | 12 +- SPECS.md | 28 +-- src/funkyjuicerecipes/data/dao/flavors.py | 18 +- src/funkyjuicerecipes/data/dao/nicotine.py | 115 ++++++++++ src/funkyjuicerecipes/data/dao/recipes.py | 69 +++++- src/funkyjuicerecipes/data/db.py | 5 +- ...-18_flavor_table_alter_inventory_fields.py | 14 ++ .../2025-12-18_nicotine_table_created.py | 24 +++ ...ecipe_flavor_table_add_flavor_snapshots.py | 15 ++ ...2-18_recipe_table_alter_nicotine_fields.py | 18 ++ ...-12-18_recipe_table_drop_legacy_nic_pct.py | 50 +++++ src/funkyjuicerecipes/data/models/flavor.py | 5 + src/funkyjuicerecipes/data/models/nicotine.py | 36 ++++ src/funkyjuicerecipes/data/models/recipe.py | 6 +- .../data/models/recipe_flavor.py | 8 +- src/funkyjuicerecipes/logic/__init__.py | 7 + src/funkyjuicerecipes/logic/calculations.py | 204 ++++++++++++++++++ tests/test_calculations.py | 158 ++++++++++++++ tests/test_db_init.py | 15 +- tests/test_models_daos.py | 59 ++++- 20 files changed, 826 insertions(+), 40 deletions(-) create mode 100644 src/funkyjuicerecipes/data/dao/nicotine.py create mode 100644 src/funkyjuicerecipes/data/migrations/2025-12-18_flavor_table_alter_inventory_fields.py create mode 100644 src/funkyjuicerecipes/data/migrations/2025-12-18_nicotine_table_created.py create mode 100644 src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_flavor_table_add_flavor_snapshots.py create mode 100644 src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_alter_nicotine_fields.py create mode 100644 src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_drop_legacy_nic_pct.py create mode 100644 src/funkyjuicerecipes/data/models/nicotine.py create mode 100644 src/funkyjuicerecipes/logic/__init__.py create mode 100644 src/funkyjuicerecipes/logic/calculations.py create mode 100644 tests/test_calculations.py diff --git a/MILESTONES.md b/MILESTONES.md index 7d96765..d23cd39 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -37,13 +37,13 @@ This document outlines the sequence of milestones to deliver the FunkyJuice Reci --- -### Milestone 4 — Calculation engine -- Implement pure functions in `logic/calculations.py` to compute PG/VG/Nic and per-flavor mL from inputs (percents + size). -- Include validation (non-negative PG/VG; base_pg + base_vg = 100; percent totals ≈ 100 within tolerance). -- Define rounding/display policy (e.g., 1 decimal for View screen). +### ~~Milestone 4 — Calculation engine~~ +- ~~Implement pure functions in `logic/calculations.py` to compute PG/VG/Nic and per-flavor mL from inputs (percents + size).~~ +- ~~Include validation (non-negative PG/VG; base_pg + base_vg = 100; percent totals ≈ 100 within tolerance).~~ +- ~~Define rounding/display policy (e.g., 1 decimal for View screen).~~ -Acceptance: -- Tests cover the README example (120 mL case) and edge cases (nic in PG vs VG, excessive flavor %). +~~Acceptance:~~ +- ~~Tests cover the README example (120 mL case) and edge cases (nic in PG vs VG, excessive flavor %).~~ --- diff --git a/SPECS.md b/SPECS.md index 5f4b91e..e6775a9 100644 --- a/SPECS.md +++ b/SPECS.md @@ -123,13 +123,15 @@ CREATE INDEX IF NOT EXISTS idx_flavor_name_company ON flavor(name, company); * Recipe Table ``` recipe( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - size_ml INTEGER NOT NULL, - base_pg_pct REAL NOT NULL, - base_vg_pct REAL NOT NULL, - nic_pct REAL CHECK(nic_pct >= 0 AND nic_pct <= 100) NOT NULL, + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + size_ml INTEGER NOT NULL, + base_pg_pct REAL NOT NULL, + base_vg_pct REAL NOT NULL, + nic_target_mg_per_ml REAL CHECK(nic_target_mg_per_ml >= 0) NOT NULL, + nic_strength_mg_per_ml REAL CHECK(nic_strength_mg_per_ml > 0) NOT NULL, nic_base TEXT CHECK(nic_base IN ('PG','VG')) NOT NULL, + nicotine_id INTEGER NULL REFERENCES nicotine(id) ON DELETE SET NULL, CHECK ( base_pg_pct >= 0 AND base_vg_pct >= 0 AND @@ -165,16 +167,18 @@ migrations( ## Recipes * Recipes require - * Name, - * Size (ml), - * PG Base %, - * VG base %, - * Nic Base (PG/VG) - * Nic %, + * Name, + * Size (ml), + * PG Base %, + * VG base %, + * Nic target strength (mg/mL), + * Nic stock strength (mg/mL), + * Nic Base (PG/VG), * Flavor List * Recipe equations are: * flavor_pg_pct = sum(pct for flavor in flavors if flavor.base == 'PG') * flavor_vg_pct = sum(pct for flavor in flavors if flavor.base == 'VG') + * nic_pct = (nic_target_mg_per_ml / nic_strength_mg_per_ml) * 100 # nicotine volume percent of final mix * pg_pct = base_pg_pct - flavor_pg_pct - (nic_pct if nic_base == 'PG' else 0) * vg_pct = base_vg_pct - flavor_vg_pct - (nic_pct if nic_base == 'VG' else 0) * PG_ml = pg_pct / 100 * size_ml diff --git a/src/funkyjuicerecipes/data/dao/flavors.py b/src/funkyjuicerecipes/data/dao/flavors.py index 5175646..f9f0603 100644 --- a/src/funkyjuicerecipes/data/dao/flavors.py +++ b/src/funkyjuicerecipes/data/dao/flavors.py @@ -36,7 +36,17 @@ class FlavorRepository: def get(self, flavor_id: int) -> Flavor: return Flavor.get_by_id(flavor_id) - def update(self, flavor_id: int, *, name: Optional[str] = None, company: Optional[str] = None, base: Optional[str] = None) -> Flavor: + def update( + self, + flavor_id: int, + *, + name: Optional[str] = None, + company: Optional[str] = None, + base: Optional[str] = None, + bottle_size_ml: Optional[float] = None, + cost: Optional[float] = None, + purchase_date: Optional[dt.datetime] = None, + ) -> Flavor: f = self.get(flavor_id) if name is not None: f.name = name @@ -44,6 +54,12 @@ class FlavorRepository: f.company = company if base is not None: f.base = base + if bottle_size_ml is not None: + f.bottle_size_ml = bottle_size_ml + if cost is not None: + f.cost = cost + if purchase_date is not None: + f.purchase_date = purchase_date f.save() return f diff --git a/src/funkyjuicerecipes/data/dao/nicotine.py b/src/funkyjuicerecipes/data/dao/nicotine.py new file mode 100644 index 0000000..e748d33 --- /dev/null +++ b/src/funkyjuicerecipes/data/dao/nicotine.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import datetime as dt +from typing import Iterable, List, Optional + +from peewee import DoesNotExist, IntegrityError + +from ..models.nicotine import Nicotine +from ..models.recipe import Recipe + + +class NicotineRepository: + def create( + self, + *, + name: str, + company: str, + base: str, + strength_mg_per_ml: float, + bottle_size_ml: Optional[float] = None, + cost: Optional[float] = None, + purchase_date: Optional[dt.datetime] = None, + ) -> Nicotine: + try: + return Nicotine.create( + name=name, + company=company, + base=base, + strength_mg_per_ml=strength_mg_per_ml, + bottle_size_ml=bottle_size_ml, + cost=cost, + purchase_date=purchase_date, + ) + except IntegrityError as e: + raise e + + def list( + self, + *, + include_deleted: bool = False, + only_deleted: bool = False, + order_by: Iterable = (Nicotine.company, Nicotine.name), + ) -> List[Nicotine]: + q = Nicotine.select() + if only_deleted: + q = q.where(Nicotine.is_deleted == True) + elif not include_deleted: + q = q.where(Nicotine.is_deleted == False) + if order_by: + q = q.order_by(*order_by) + return list(q) + + def get(self, nicotine_id: int) -> Nicotine: + return Nicotine.get_by_id(nicotine_id) + + def update( + self, + nicotine_id: int, + *, + name: Optional[str] = None, + company: Optional[str] = None, + base: Optional[str] = None, + strength_mg_per_ml: Optional[float] = None, + bottle_size_ml: Optional[float] = None, + cost: Optional[float] = None, + purchase_date: Optional[dt.datetime] = None, + ) -> Nicotine: + n = self.get(nicotine_id) + if name is not None: + n.name = name + if company is not None: + n.company = company + if base is not None: + n.base = base + if strength_mg_per_ml is not None: + n.strength_mg_per_ml = strength_mg_per_ml + if bottle_size_ml is not None: + n.bottle_size_ml = bottle_size_ml + if cost is not None: + n.cost = cost + if purchase_date is not None: + n.purchase_date = purchase_date + n.save() + return n + + def soft_delete(self, nicotine_id: int) -> bool: + n = self.get(nicotine_id) + if not n.is_deleted: + n.is_deleted = True + n.deleted_at = dt.datetime.utcnow() + n.save() + return True + + def undelete(self, nicotine_id: int) -> bool: + n = self.get(nicotine_id) + if n.is_deleted: + n.is_deleted = False + n.deleted_at = None + n.save() + return True + + def hard_delete_when_safe(self, nicotine_id: int) -> bool: + # If referenced by recipes (FK), block hard delete + ref_exists = Recipe.select().where(Recipe.nicotine == nicotine_id).exists() + if ref_exists: + return False + try: + n = self.get(nicotine_id) + except DoesNotExist: + return True + n.delete_instance() + return True + + +__all__ = ["NicotineRepository"] diff --git a/src/funkyjuicerecipes/data/dao/recipes.py b/src/funkyjuicerecipes/data/dao/recipes.py index d3133e2..f18548f 100644 --- a/src/funkyjuicerecipes/data/dao/recipes.py +++ b/src/funkyjuicerecipes/data/dao/recipes.py @@ -7,6 +7,7 @@ from peewee import IntegrityError from ..models.recipe import Recipe from ..models.recipe_flavor import RecipeFlavor from ..models.flavor import Flavor +from ..models.nicotine import Nicotine Ingredient = Tuple[int, float] # (flavor_id, pct) @@ -20,17 +21,47 @@ class RecipeRepository: size_ml: int, base_pg_pct: float, base_vg_pct: float, - nic_pct: float, - nic_base: str, + nic_target_mg_per_ml: float, + nic_strength_mg_per_ml: Optional[float] = None, + nic_base: Optional[str] = None, + nicotine_id: Optional[int] = None, ingredients: Optional[Sequence[Ingredient]] = None, ) -> Recipe: + # If a nicotine inventory item is provided, use it to fill snapshot defaults + inv_strength: Optional[float] = None + inv_base: Optional[str] = None + inv_obj: Optional[Nicotine] = None + if nicotine_id is not None: + try: + inv_obj = Nicotine.get_by_id(nicotine_id) + inv_strength = float(inv_obj.strength_mg_per_ml) + inv_base = str(inv_obj.base) + except Exception: + inv_obj = None + inv_strength = None + inv_base = None + + # Resolve snapshot values: explicit args take precedence, then inventory, then defaults + resolved_strength = ( + float(nic_strength_mg_per_ml) + if nic_strength_mg_per_ml is not None + else (inv_strength if inv_strength is not None else 100.0) + ) + resolved_base = ( + str(nic_base) + if nic_base is not None + else (inv_base if inv_base is not None else "PG") + ) + recipe = Recipe.create( name=name, size_ml=size_ml, base_pg_pct=base_pg_pct, base_vg_pct=base_vg_pct, - nic_pct=nic_pct, - nic_base=nic_base, + nic_target_mg_per_ml=nic_target_mg_per_ml, + nic_strength_mg_per_ml=resolved_strength, + nic_base=resolved_base, + nicotine=inv_obj if inv_obj is not None else None, ) if ingredients: self.replace_ingredients(recipe.id, ingredients) @@ -60,8 +91,10 @@ class RecipeRepository: size_ml: Optional[int] = None, base_pg_pct: Optional[float] = None, base_vg_pct: Optional[float] = None, - nic_pct: Optional[float] = None, + nic_target_mg_per_ml: Optional[float] = None, + nic_strength_mg_per_ml: Optional[float] = None, nic_base: Optional[str] = None, + nicotine_id: Optional[int] = None, ) -> Recipe: r = self.get(recipe_id) if name is not None: @@ -72,10 +105,17 @@ class RecipeRepository: r.base_pg_pct = base_pg_pct if base_vg_pct is not None: r.base_vg_pct = base_vg_pct - if nic_pct is not None: - r.nic_pct = nic_pct + if nic_target_mg_per_ml is not None: + r.nic_target_mg_per_ml = nic_target_mg_per_ml + if nic_strength_mg_per_ml is not None: + r.nic_strength_mg_per_ml = nic_strength_mg_per_ml if nic_base is not None: r.nic_base = nic_base + if nicotine_id is not None: + try: + r.nicotine = Nicotine.get_by_id(nicotine_id) + except Exception: + r.nicotine = None r.save() return r @@ -133,7 +173,20 @@ class RecipeRepository: ) for fid in to_add: - RecipeFlavor.create(recipe=recipe_id, flavor=fid, pct=desired[fid]) + # Populate flavor snapshots at time of insert + try: + fl = Flavor.get_by_id(fid) + RecipeFlavor.create( + recipe=recipe_id, + flavor=fid, + pct=desired[fid], + flavor_name_snapshot=fl.name, + flavor_company_snapshot=fl.company, + flavor_base_snapshot=fl.base, + ) + except Exception: + # If flavor not found (shouldn't happen due to FK), fall back without snapshots + RecipeFlavor.create(recipe=recipe_id, flavor=fid, pct=desired[fid]) def delete(self, recipe_id: int) -> None: r = self.get(recipe_id) diff --git a/src/funkyjuicerecipes/data/db.py b/src/funkyjuicerecipes/data/db.py index 2de5c14..ddcc96f 100644 --- a/src/funkyjuicerecipes/data/db.py +++ b/src/funkyjuicerecipes/data/db.py @@ -85,8 +85,9 @@ def _migration_modules() -> List[str]: name = res.name if name.endswith(".py") and not name.startswith("_"): files.append(name) - # Sort by filename to define application order - files.sort() + # Sort by filename, but ensure "*_created*" migrations run before others + # so that any later ALTERs for the same table don't fail on fresh DBs. + files.sort(key=lambda n: (0 if "_created" in n else 1, n)) return files diff --git a/src/funkyjuicerecipes/data/migrations/2025-12-18_flavor_table_alter_inventory_fields.py b/src/funkyjuicerecipes/data/migrations/2025-12-18_flavor_table_alter_inventory_fields.py new file mode 100644 index 0000000..228534e --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/2025-12-18_flavor_table_alter_inventory_fields.py @@ -0,0 +1,14 @@ +from __future__ import annotations + + +def apply(db): + # Add optional inventory metadata to flavor + db.execute_sql( + "ALTER TABLE flavor ADD COLUMN bottle_size_ml REAL NULL" + ) + db.execute_sql( + "ALTER TABLE flavor ADD COLUMN cost REAL NULL" + ) + db.execute_sql( + "ALTER TABLE flavor ADD COLUMN purchase_date DATETIME NULL" + ) diff --git a/src/funkyjuicerecipes/data/migrations/2025-12-18_nicotine_table_created.py b/src/funkyjuicerecipes/data/migrations/2025-12-18_nicotine_table_created.py new file mode 100644 index 0000000..9345755 --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/2025-12-18_nicotine_table_created.py @@ -0,0 +1,24 @@ +from __future__ import annotations + + +def apply(db): + db.execute_sql( + ( + "CREATE TABLE IF NOT EXISTS nicotine (\n" + " id INTEGER PRIMARY KEY,\n" + " name TEXT COLLATE NOCASE NOT NULL,\n" + " company TEXT COLLATE NOCASE NOT NULL,\n" + " base TEXT CHECK(base IN ('PG','VG')) NOT NULL,\n" + " strength_mg_per_ml REAL CHECK(strength_mg_per_ml > 0) NOT NULL,\n" + " bottle_size_ml REAL NULL,\n" + " cost REAL NULL,\n" + " purchase_date DATETIME NULL,\n" + " is_deleted INTEGER NOT NULL DEFAULT 0,\n" + " deleted_at DATETIME NULL,\n" + " UNIQUE(company, name, strength_mg_per_ml, base)\n" + ")" + ) + ) + db.execute_sql( + "CREATE INDEX IF NOT EXISTS idx_nicotine_company_name ON nicotine(company, name)" + ) diff --git a/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_flavor_table_add_flavor_snapshots.py b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_flavor_table_add_flavor_snapshots.py new file mode 100644 index 0000000..1645f92 --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_flavor_table_add_flavor_snapshots.py @@ -0,0 +1,15 @@ +from __future__ import annotations + + +def apply(db): + # Add snapshot columns for flavor identity at time of recipe creation + # Allow NULL for backward compatibility; application code will populate on insert. + db.execute_sql( + "ALTER TABLE recipe_flavor ADD COLUMN flavor_name_snapshot TEXT NULL" + ) + db.execute_sql( + "ALTER TABLE recipe_flavor ADD COLUMN flavor_company_snapshot TEXT COLLATE NOCASE NULL" + ) + db.execute_sql( + "ALTER TABLE recipe_flavor ADD COLUMN flavor_base_snapshot TEXT NULL CHECK(flavor_base_snapshot IN ('PG','VG'))" + ) diff --git a/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_alter_nicotine_fields.py b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_alter_nicotine_fields.py new file mode 100644 index 0000000..ac49dd0 --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_alter_nicotine_fields.py @@ -0,0 +1,18 @@ +from __future__ import annotations + + +def apply(db): + # Add columns for nicotine snapshot and optional inventory FK reference + db.execute_sql( + "ALTER TABLE recipe ADD COLUMN nic_target_mg_per_ml REAL NOT NULL DEFAULT 0" + ) + db.execute_sql( + "ALTER TABLE recipe ADD COLUMN nic_strength_mg_per_ml REAL NOT NULL DEFAULT 100.0" + ) + db.execute_sql( + "ALTER TABLE recipe ADD COLUMN nicotine_id INTEGER NULL" + ) + # Optional index to speed up lookups by nicotine_id + db.execute_sql( + "CREATE INDEX IF NOT EXISTS idx_recipe_nicotine_id ON recipe(nicotine_id)" + ) diff --git a/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_drop_legacy_nic_pct.py b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_drop_legacy_nic_pct.py new file mode 100644 index 0000000..6a360f1 --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_drop_legacy_nic_pct.py @@ -0,0 +1,50 @@ +from __future__ import annotations + + +def apply(db): + """Remove legacy nic_pct from recipe by rebuilding the table. + + This approach is compatible with older SQLite versions that don't support + ALTER TABLE ... DROP COLUMN. + """ + # Create new table without nic_pct, preserving constraints and FK + db.execute_sql( + ( + "CREATE TABLE IF NOT EXISTS recipe__new (\n" + " id INTEGER PRIMARY KEY,\n" + " name TEXT NOT NULL UNIQUE,\n" + " size_ml INTEGER NOT NULL,\n" + " base_pg_pct REAL NOT NULL,\n" + " base_vg_pct REAL NOT NULL,\n" + " nic_target_mg_per_ml REAL CHECK(nic_target_mg_per_ml >= 0) NOT NULL,\n" + " nic_strength_mg_per_ml REAL CHECK(nic_strength_mg_per_ml > 0) NOT NULL,\n" + " nic_base TEXT CHECK(nic_base IN ('PG','VG')) NOT NULL,\n" + " nicotine_id INTEGER NULL REFERENCES nicotine(id) ON DELETE SET NULL,\n" + " CHECK (\n" + " base_pg_pct >= 0 AND\n" + " base_vg_pct >= 0 AND\n" + " (base_pg_pct + base_vg_pct) = 100\n" + " )\n" + ")" + ) + ) + + # Copy data (exclude nic_pct) + db.execute_sql( + ( + "INSERT INTO recipe__new (id, name, size_ml, base_pg_pct, base_vg_pct, " + "nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, nicotine_id) " + "SELECT id, name, size_ml, base_pg_pct, base_vg_pct, " + " nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, nicotine_id " + "FROM recipe" + ) + ) + + # Drop old table and rename new + db.execute_sql("DROP TABLE recipe") + db.execute_sql("ALTER TABLE recipe__new RENAME TO recipe") + + # Recreate any indexes that referenced the table (unique on name is inline; add index on nicotine_id) + db.execute_sql( + "CREATE INDEX IF NOT EXISTS idx_recipe_nicotine_id ON recipe(nicotine_id)" + ) diff --git a/src/funkyjuicerecipes/data/models/flavor.py b/src/funkyjuicerecipes/data/models/flavor.py index 8b10315..a75bd46 100644 --- a/src/funkyjuicerecipes/data/models/flavor.py +++ b/src/funkyjuicerecipes/data/models/flavor.py @@ -5,6 +5,7 @@ from peewee import ( BooleanField, DateTimeField, Check, + FloatField, ) from .base import BaseModel @@ -14,6 +15,10 @@ class Flavor(BaseModel): name = CharField(collation="NOCASE") company = CharField(collation="NOCASE") base = CharField(constraints=[Check("base IN ('PG','VG')")]) + # Optional inventory metadata + bottle_size_ml = FloatField(null=True) + cost = FloatField(null=True) + purchase_date = DateTimeField(null=True) is_deleted = BooleanField(default=False) deleted_at = DateTimeField(null=True) diff --git a/src/funkyjuicerecipes/data/models/nicotine.py b/src/funkyjuicerecipes/data/models/nicotine.py new file mode 100644 index 0000000..565962b --- /dev/null +++ b/src/funkyjuicerecipes/data/models/nicotine.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from peewee import ( + CharField, + BooleanField, + DateTimeField, + Check, + FloatField, +) + +from .base import BaseModel + + +class Nicotine(BaseModel): + name = CharField(collation="NOCASE") + company = CharField(collation="NOCASE") + base = CharField(constraints=[Check("base IN ('PG','VG')")]) + strength_mg_per_ml = FloatField(constraints=[Check("strength_mg_per_ml > 0")]) + + # Optional inventory metadata + bottle_size_ml = FloatField(null=True) + cost = FloatField(null=True) + purchase_date = DateTimeField(null=True) + + # Soft delete + is_deleted = BooleanField(default=False) + deleted_at = DateTimeField(null=True) + + class Meta: + table_name = "nicotine" + indexes = ( + (("company", "name", "strength_mg_per_ml", "base"), True), + ) + + +__all__ = ["Nicotine"] diff --git a/src/funkyjuicerecipes/data/models/recipe.py b/src/funkyjuicerecipes/data/models/recipe.py index 26a8276..c382bc6 100644 --- a/src/funkyjuicerecipes/data/models/recipe.py +++ b/src/funkyjuicerecipes/data/models/recipe.py @@ -6,9 +6,11 @@ from peewee import ( FloatField, Check, SQL, + ForeignKeyField, ) from .base import BaseModel +from .nicotine import Nicotine class Recipe(BaseModel): @@ -16,8 +18,10 @@ class Recipe(BaseModel): size_ml = IntegerField(constraints=[Check("size_ml > 0")]) base_pg_pct = FloatField(constraints=[Check("base_pg_pct >= 0")]) base_vg_pct = FloatField(constraints=[Check("base_vg_pct >= 0")]) - nic_pct = FloatField(constraints=[Check("nic_pct >= 0 AND nic_pct <= 100")]) + nic_target_mg_per_ml = FloatField(constraints=[Check("nic_target_mg_per_ml >= 0")]) + nic_strength_mg_per_ml = FloatField(constraints=[Check("nic_strength_mg_per_ml > 0")]) nic_base = CharField(constraints=[Check("nic_base IN ('PG','VG')")]) + nicotine = ForeignKeyField(Nicotine, null=True, backref="recipes", on_delete="SET NULL") class Meta: table_name = "recipe" diff --git a/src/funkyjuicerecipes/data/models/recipe_flavor.py b/src/funkyjuicerecipes/data/models/recipe_flavor.py index 14925f8..862cb83 100644 --- a/src/funkyjuicerecipes/data/models/recipe_flavor.py +++ b/src/funkyjuicerecipes/data/models/recipe_flavor.py @@ -1,6 +1,6 @@ from __future__ import annotations -from peewee import ForeignKeyField, FloatField, Check +from peewee import ForeignKeyField, FloatField, Check, CharField from .base import BaseModel from .flavor import Flavor @@ -11,6 +11,12 @@ class RecipeFlavor(BaseModel): recipe = ForeignKeyField(Recipe, backref="ingredients", on_delete="CASCADE") flavor = ForeignKeyField(Flavor, backref="recipes", on_delete="RESTRICT") pct = FloatField(constraints=[Check("pct >= 0 AND pct <= 100")]) + # Snapshots of flavor identity at the time the ingredient entry is created + flavor_name_snapshot = CharField(null=True) + flavor_company_snapshot = CharField(null=True, collation="NOCASE") + flavor_base_snapshot = CharField( + null=True, constraints=[Check("flavor_base_snapshot IN ('PG','VG')")] + ) class Meta: table_name = "recipe_flavor" diff --git a/src/funkyjuicerecipes/logic/__init__.py b/src/funkyjuicerecipes/logic/__init__.py new file mode 100644 index 0000000..d6a6ff6 --- /dev/null +++ b/src/funkyjuicerecipes/logic/__init__.py @@ -0,0 +1,7 @@ +"""Business logic utilities. + +This package contains the calculation engine used to compute recipe component +amounts (PG, VG, Nicotine, and per-flavor volumes) from recipe inputs. +""" + +__all__ = [] diff --git a/src/funkyjuicerecipes/logic/calculations.py b/src/funkyjuicerecipes/logic/calculations.py new file mode 100644 index 0000000..d46a4bf --- /dev/null +++ b/src/funkyjuicerecipes/logic/calculations.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, List, Mapping, Sequence, Tuple, Union + + +FlavorInput = Union[ + Mapping[str, Union[str, float]], + Tuple[str, str, float], # (name, base, pct) +] + + +@dataclass(frozen=True) +class FlavorAmount: + name: str + base: str # 'PG' or 'VG' + pct: float + ml: float + + +@dataclass(frozen=True) +class RecipeBreakdown: + size_ml: int + pg_pct: float + vg_pct: float + nic_pct: float # volume percent of nicotine stock used in final mix + nic_base: str + # Extra nicotine-related fields for clarity + nic_target_mg_per_ml: float # target final strength + nic_strength_mg_per_ml: float # stock/base concentration + pg_ml: float + vg_ml: float + nic_ml: float + flavor_prep_ml: float + flavors: Tuple[FlavorAmount, ...] + + +EPS = 1e-7 + + +def _norm_flavor(item: FlavorInput) -> Tuple[str, str, float]: + if isinstance(item, tuple) and len(item) == 3: + name, base, pct = item + return str(name), str(base), float(pct) + elif isinstance(item, Mapping): + name = str(item.get("name", "")) + base = str(item.get("base")) + pct = float(item.get("pct")) + return name, base, pct + else: + raise TypeError("Flavor input must be a mapping with keys (name, base, pct) or a tuple (name, base, pct)") + + +def _validate_inputs( + *, + size_ml: int, + base_pg_pct: float, + base_vg_pct: float, + nic_target_mg_per_ml: float, + nic_strength_mg_per_ml: float, + nic_base: str, + flavors: Sequence[Tuple[str, str, float]], +) -> None: + if size_ml <= 0: + raise ValueError("size_ml must be positive") + if abs((base_pg_pct + base_vg_pct) - 100.0) > 1e-6: + raise ValueError("Base PG% + VG% must equal 100") + if nic_base not in ("PG", "VG"): + raise ValueError("nic_base must be 'PG' or 'VG'") + if nic_strength_mg_per_ml <= 0: + raise ValueError("nic_strength_mg_per_ml must be > 0") + if nic_target_mg_per_ml < 0: + raise ValueError("nic_target_mg_per_ml must be >= 0") + for _, base, pct in flavors: + if base not in ("PG", "VG"): + raise ValueError("Flavor base must be 'PG' or 'VG'") + if not (0.0 <= pct <= 100.0): + raise ValueError("Flavor pct must be between 0 and 100") + + +def compute_breakdown( + *, + size_ml: int, + base_pg_pct: float, + base_vg_pct: float, + nic_target_mg_per_ml: float, + nic_strength_mg_per_ml: float, + nic_base: str, + flavors: Sequence[FlavorInput], +) -> RecipeBreakdown: + """Compute recipe component amounts. + + Parameters are pure data; this function does not touch the DB or UI. + `flavors` can be a sequence of dicts with keys `name`, `base`, `pct`, + or tuples `(name, base, pct)`. + """ + normalized: List[Tuple[str, str, float]] = [_norm_flavor(f) for f in flavors] + + _validate_inputs( + size_ml=size_ml, + base_pg_pct=base_pg_pct, + base_vg_pct=base_vg_pct, + nic_target_mg_per_ml=nic_target_mg_per_ml, + nic_strength_mg_per_ml=nic_strength_mg_per_ml, + nic_base=nic_base, + flavors=normalized, + ) + + # Compute required nicotine stock volume percent for the target strength + nic_pct = (float(nic_target_mg_per_ml) / float(nic_strength_mg_per_ml)) * 100.0 + if nic_pct > 100.0 + 1e-9: + raise ValueError("Target nicotine exceeds stock strength (requires >100% nic volume)") + + flavor_pg_pct = sum(pct for _, base, pct in normalized if base == "PG") + flavor_vg_pct = sum(pct for _, base, pct in normalized if base == "VG") + + pg_pct = base_pg_pct - flavor_pg_pct - (nic_pct if nic_base == "PG" else 0.0) + vg_pct = base_vg_pct - flavor_vg_pct - (nic_pct if nic_base == "VG" else 0.0) + + # Validation: PG/VG must not be negative (within tolerance) + if pg_pct < -EPS or vg_pct < -EPS: + raise ValueError("Computed PG/VG would be negative. Reduce flavor or nicotine percentages.") + + # Clamp tiny negatives to zero to account for float rounding + if pg_pct < 0 and pg_pct > -EPS: + pg_pct = 0.0 + if vg_pct < 0 and vg_pct > -EPS: + vg_pct = 0.0 + + # Validate total ~ 100 + total = flavor_pg_pct + flavor_vg_pct + nic_pct + pg_pct + vg_pct + if abs(total - 100.0) > 1e-5: + raise ValueError("Component percentages must sum to ~100% within tolerance") + + # Convert to mL + def pct_to_ml(p: float) -> float: + return p / 100.0 * float(size_ml) + + pg_ml = pct_to_ml(pg_pct) + vg_ml = pct_to_ml(vg_pct) + nic_ml = pct_to_ml(nic_pct) + + flavors_out: List[FlavorAmount] = [] + for name, base, pct in normalized: + flavors_out.append(FlavorAmount(name=name, base=base, pct=pct, ml=pct_to_ml(pct))) + + flavor_prep_ml = pct_to_ml(flavor_pg_pct + flavor_vg_pct) + + return RecipeBreakdown( + size_ml=int(size_ml), + pg_pct=float(pg_pct), + vg_pct=float(vg_pct), + nic_pct=float(nic_pct), + nic_base=str(nic_base), + nic_target_mg_per_ml=float(nic_target_mg_per_ml), + nic_strength_mg_per_ml=float(nic_strength_mg_per_ml), + pg_ml=float(pg_ml), + vg_ml=float(vg_ml), + nic_ml=float(nic_ml), + flavor_prep_ml=float(flavor_prep_ml), + flavors=tuple(flavors_out), + ) + + +def round_ml(value: float, decimals: int = 1) -> float: + """Round a milliliter value for display. Default 1 decimal place. + + Uses standard rounding behavior. + """ + return round(float(value), decimals) + + +def breakdown_for_view(breakdown: RecipeBreakdown, decimals: int = 1) -> RecipeBreakdown: + """Return a copy of breakdown with mL values rounded for display. + + Percentages are left unchanged; only mL fields are rounded. + """ + rounded_flavors = tuple( + FlavorAmount(name=f.name, base=f.base, pct=f.pct, ml=round_ml(f.ml, decimals)) + for f in breakdown.flavors + ) + return RecipeBreakdown( + size_ml=breakdown.size_ml, + pg_pct=breakdown.pg_pct, + vg_pct=breakdown.vg_pct, + nic_pct=breakdown.nic_pct, + nic_base=breakdown.nic_base, + nic_target_mg_per_ml=breakdown.nic_target_mg_per_ml, + nic_strength_mg_per_ml=breakdown.nic_strength_mg_per_ml, + pg_ml=round_ml(breakdown.pg_ml, decimals), + vg_ml=round_ml(breakdown.vg_ml, decimals), + nic_ml=round_ml(breakdown.nic_ml, decimals), + flavor_prep_ml=round_ml(breakdown.flavor_prep_ml, decimals), + flavors=rounded_flavors, + ) + + +__all__ = [ + "FlavorAmount", + "RecipeBreakdown", + "compute_breakdown", + "round_ml", + "breakdown_for_view", +] diff --git a/tests/test_calculations.py b/tests/test_calculations.py new file mode 100644 index 0000000..f9d87bb --- /dev/null +++ b/tests/test_calculations.py @@ -0,0 +1,158 @@ +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"} diff --git a/tests/test_db_init.py b/tests/test_db_init.py index 9b82f41..2e6b161 100644 --- a/tests/test_db_init.py +++ b/tests/test_db_init.py @@ -49,20 +49,23 @@ def test_init_database_creates_file_and_tables(tmp_path: Path): # Use sqlite3 to introspect tables with sqlite3.connect(db_path) as conn: names = _tables(conn) - # migrations + our 3 domain tables - assert {"migrations", "flavor", "recipe", "recipe_flavor"}.issubset(names) + # migrations + our domain tables (including nicotine) + assert {"migrations", "flavor", "recipe", "recipe_flavor", "nicotine"}.issubset(names) # migrations table should have entries for all applied migrations cur = conn.execute("SELECT filename, batch FROM migrations ORDER BY filename") rows = cur.fetchall() - assert len(rows) == 3 + # We now have more migrations over time; ensure at least the base set were applied + assert len(rows) >= 3 filenames = [r[0] for r in rows] # Ensure expected filenames are present assert filenames == sorted(filenames) assert any("flavor_table_created" in f for f in filenames) assert any("recipe_table_created" in f for f in filenames) assert any("recipe_flavor_table_created" in f for f in filenames) - # All three should be recorded in the same batch + # New nicotine/inventory migrations should also be present + assert any("nicotine_table_created" in f for f in filenames) + # All migrations applied in one run should share the same batch batches = {r[1] for r in rows} assert len(batches) == 1 @@ -75,9 +78,9 @@ def test_apply_migrations_is_idempotent(tmp_path: Path): pending = apply_migrations(db) assert pending == [] - # migrations count remains 3 + # migrations count remains stable after re-run db_path = Path(app.paths.data) / DB_FILENAME with sqlite3.connect(db_path) as conn: cur = conn.execute("SELECT COUNT(*) FROM migrations") (count,) = cur.fetchone() - assert count == 3 + assert count >= 3 diff --git a/tests/test_models_daos.py b/tests/test_models_daos.py index 9f704b4..70b5cde 100644 --- a/tests/test_models_daos.py +++ b/tests/test_models_daos.py @@ -12,6 +12,7 @@ from funkyjuicerecipes.data.dao.flavors import FlavorRepository from funkyjuicerecipes.data.dao.recipes import RecipeRepository from funkyjuicerecipes.data.models.flavor import Flavor from funkyjuicerecipes.data.models.recipe_flavor import RecipeFlavor +from funkyjuicerecipes.data.models.flavor import Flavor as FlavorModel class DummyPaths(SimpleNamespace): @@ -84,7 +85,7 @@ def test_flavor_soft_delete_and_listing_filters(tmp_path: Path): size_ml=30, base_pg_pct=50, base_vg_pct=50, - nic_pct=0, + nic_target_mg_per_ml=0, nic_base="PG", ingredients=[(f1.id, 5.0)], ) @@ -113,7 +114,7 @@ def test_recipe_creation_and_get_with_ingredients_includes_soft_deleted_flavors( size_ml=60, base_pg_pct=70, base_vg_pct=30, - nic_pct=3, + nic_target_mg_per_ml=3, nic_base="PG", ingredients=[(fa.id, 4.0), (fb.id, 2.0)], ) @@ -142,7 +143,7 @@ def test_replace_ingredients_diff_behavior(tmp_path: Path): size_ml=30, base_pg_pct=50, base_vg_pct=50, - nic_pct=0, + nic_target_mg_per_ml=0, nic_base="PG", ingredients=[(f1.id, 3.0), (f2.id, 2.0)], ) @@ -179,3 +180,55 @@ def test_replace_ingredients_diff_behavior(tmp_path: Path): assert RecipeFlavor.select().where( (RecipeFlavor.recipe == r.id) & (RecipeFlavor.flavor == f3.id) ).exists() + + +def test_flavor_snapshots_populated_and_stable(tmp_path: Path): + app, db = _init_db(tmp_path) + flavors = FlavorRepository() + recipes = RecipeRepository() + + # Create a flavor and recipe using it + f = flavors.create(name="Vanilla", company="CAP", base="PG") + r = recipes.create( + name="Vanilla Mix", + size_ml=30, + base_pg_pct=50, + base_vg_pct=50, + nic_target_mg_per_ml=0, + nic_base="PG", + ingredients=[(f.id, 5.0)], + ) + + rf = RecipeFlavor.get(RecipeFlavor.recipe == r.id) + assert rf.flavor_id == f.id + # Snapshots should be populated at creation time + assert rf.flavor_name_snapshot == "Vanilla" + assert rf.flavor_company_snapshot == "CAP" + assert rf.flavor_base_snapshot == "PG" + + # Change the live Flavor fields + flavors.update(f.id, name="Vanilla Bean", company="Capella", base="VG") + + # Snapshots must remain unchanged + rf2 = RecipeFlavor.get_by_id(rf.id) + assert rf2.flavor_name_snapshot == "Vanilla" + assert rf2.flavor_company_snapshot == "CAP" + assert rf2.flavor_base_snapshot == "PG" + + # Pct-only update should not touch snapshots + recipes.replace_ingredients(r.id, [(f.id, 7.5)]) + rf3 = RecipeFlavor.get(RecipeFlavor.recipe == r.id) + assert rf3.pct == 7.5 + assert rf3.flavor_name_snapshot == "Vanilla" + assert rf3.flavor_company_snapshot == "CAP" + assert rf3.flavor_base_snapshot == "PG" + + # Replacing with a different flavor should capture new snapshots + f2 = flavors.create(name="Cookie", company="FA", base="VG") + recipes.replace_ingredients(r.id, [(f2.id, 2.0)]) + rf_after = RecipeFlavor.get(RecipeFlavor.recipe == r.id) + assert rf_after.flavor_id == f2.id + assert rf_after.pct == 2.0 + assert rf_after.flavor_name_snapshot == "Cookie" + assert rf_after.flavor_company_snapshot == "FA" + assert rf_after.flavor_base_snapshot == "VG"