kiwi/tests/services/test_sensory.py
pyr0ball 521cb419bc feat: sensory profile filter — texture/smell/noise filtering for Browse and Find (kiwi#51)
- Migration 035: add sensory_tags column to recipes (default '{}')
- scripts/tag_sensory_profiles.py: batch tagger using ingredient names,
  direction keywords, and ingredient_profiles texture data
- app/services/recipe/sensory.py: SensoryExclude frozen dataclass,
  build_sensory_exclude(), passes_sensory_filter() with graceful degradation
  (untagged recipes always pass; malformed JSON always passes)
- store.browse_recipes and _browse_by_match: accept SensoryExclude, apply
  filter in recipe-building loop (default path) and scoring loop (match sort)
- recipe_engine.suggest: load sensory_preferences from settings, apply
  passes_sensory_filter() after exclude_set check in the rows loop
- settings endpoint: add sensory_preferences to _ALLOWED_KEYS
- Frontend: SensoryPreferences types in api.ts; sensoryPreferences state and
  saveSensory() action in settings store; Sensory section in SettingsView with
  texture avoid pills, smell/noise tolerance scale pills with ok/limit/neutral
  color coding
- 66 new tests (29 classification + 13 sensory service + 2 settings); 281 total
2026-04-24 09:47:48 -07:00

130 lines
5.4 KiB
Python

"""Tests for app/services/recipe/sensory.py."""
from __future__ import annotations
import json
from app.services.recipe.sensory import (
SensoryExclude,
build_sensory_exclude,
passes_sensory_filter,
)
class TestBuildSensoryExclude:
def test_none_input_returns_empty(self):
assert build_sensory_exclude(None).is_empty()
def test_empty_string_returns_empty(self):
assert build_sensory_exclude("").is_empty()
def test_malformed_json_returns_empty(self):
assert build_sensory_exclude("{not valid json}").is_empty()
def test_parses_avoid_textures(self):
prefs = json.dumps({"avoid_textures": ["mushy", "slimy"], "max_smell": None, "max_noise": None})
result = build_sensory_exclude(prefs)
assert "mushy" in result.textures
assert "slimy" in result.textures
def test_parses_max_smell(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": "pungent", "max_noise": None})
result = build_sensory_exclude(prefs)
assert result.smell_above == "pungent"
def test_parses_max_noise(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": "loud"})
result = build_sensory_exclude(prefs)
assert result.noise_above == "loud"
def test_unknown_smell_level_becomes_none(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": "extremely_pungent", "max_noise": None})
result = build_sensory_exclude(prefs)
assert result.smell_above is None
def test_unknown_noise_level_becomes_none(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": "ear_splitting"})
result = build_sensory_exclude(prefs)
assert result.noise_above is None
def test_null_max_smell_is_none(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": None})
assert build_sensory_exclude(prefs).smell_above is None
def test_is_empty_all_none(self):
prefs = json.dumps({"avoid_textures": [], "max_smell": None, "max_noise": None})
assert build_sensory_exclude(prefs).is_empty()
def test_is_not_empty_with_textures(self):
prefs = json.dumps({"avoid_textures": ["mushy"]})
assert not build_sensory_exclude(prefs).is_empty()
class TestPassesSensoryFilter:
def _tags(self, textures=None, smell="mild", noise="quiet") -> str:
return json.dumps({"textures": textures or [], "smell": smell, "noise": noise})
def test_empty_exclude_always_passes(self):
tags = self._tags(textures=["mushy"], smell="fermented", noise="very_loud")
assert passes_sensory_filter(tags, SensoryExclude.empty()) is True
def test_untagged_recipe_always_passes(self):
exclude = SensoryExclude(textures=("mushy",), smell_above="pungent")
assert passes_sensory_filter("{}", exclude) is True
assert passes_sensory_filter(None, exclude) is True
assert passes_sensory_filter({}, exclude) is True
def test_texture_hit_returns_false(self):
tags = self._tags(textures=["mushy", "creamy"])
exclude = SensoryExclude(textures=("mushy",))
assert passes_sensory_filter(tags, exclude) is False
def test_texture_no_overlap_passes(self):
tags = self._tags(textures=["crunchy"])
exclude = SensoryExclude(textures=("mushy", "slimy"))
assert passes_sensory_filter(tags, exclude) is True
def test_smell_above_threshold_excluded(self):
tags = self._tags(smell="fermented")
exclude = SensoryExclude(smell_above="pungent")
assert passes_sensory_filter(tags, exclude) is False
def test_smell_at_threshold_passes(self):
tags = self._tags(smell="pungent")
exclude = SensoryExclude(smell_above="pungent")
assert passes_sensory_filter(tags, exclude) is True
def test_smell_below_threshold_passes(self):
for smell in ("aromatic", "mild"):
tags = self._tags(smell=smell)
exclude = SensoryExclude(smell_above="pungent")
assert passes_sensory_filter(tags, exclude) is True
def test_noise_above_threshold_excluded(self):
tags = self._tags(noise="very_loud")
exclude = SensoryExclude(noise_above="loud")
assert passes_sensory_filter(tags, exclude) is False
def test_noise_at_threshold_passes(self):
tags = self._tags(noise="loud")
exclude = SensoryExclude(noise_above="loud")
assert passes_sensory_filter(tags, exclude) is True
def test_noise_below_threshold_passes(self):
for noise in ("quiet", "moderate"):
tags = self._tags(noise=noise)
exclude = SensoryExclude(noise_above="loud")
assert passes_sensory_filter(tags, exclude) is True
def test_combined_texture_and_smell(self):
tags = self._tags(textures=["creamy"], smell="fermented")
exclude = SensoryExclude(textures=("creamy",), smell_above="pungent")
assert passes_sensory_filter(tags, exclude) is False
def test_dict_input_works(self):
tags_dict = {"textures": ["mushy"], "smell": "mild", "noise": "quiet"}
exclude = SensoryExclude(textures=("mushy",))
assert passes_sensory_filter(tags_dict, exclude) is False
def test_malformed_sensory_tags_passes(self):
exclude = SensoryExclude(textures=("mushy",), smell_above="pungent")
assert passes_sensory_filter("{bad json", exclude) is True