From 4c27cf4bd0552198895859e00fe8e18f94444c4d Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Sun, 12 Apr 2026 17:19:24 -0700 Subject: [PATCH] feat(community): add CommunityPost frozen dataclass with element snapshot schema --- circuitforge_core/community/db.py | 7 ++ circuitforge_core/community/models.py | 94 +++++++++++++++++++++++++++ circuitforge_core/community/store.py | 7 ++ tests/community/__init__.py | 0 tests/community/test_models.py | 94 +++++++++++++++++++++++++++ 5 files changed, 202 insertions(+) create mode 100644 circuitforge_core/community/db.py create mode 100644 circuitforge_core/community/models.py create mode 100644 circuitforge_core/community/store.py create mode 100644 tests/community/__init__.py create mode 100644 tests/community/test_models.py diff --git a/circuitforge_core/community/db.py b/circuitforge_core/community/db.py new file mode 100644 index 0000000..0fc109f --- /dev/null +++ b/circuitforge_core/community/db.py @@ -0,0 +1,7 @@ +# circuitforge_core/community/db.py +# MIT License +# Stub — implemented in full by Task 4 + + +class CommunityDB: + pass diff --git a/circuitforge_core/community/models.py b/circuitforge_core/community/models.py new file mode 100644 index 0000000..4a52233 --- /dev/null +++ b/circuitforge_core/community/models.py @@ -0,0 +1,94 @@ +# circuitforge_core/community/models.py +# MIT License + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal + +PostType = Literal["plan", "recipe_success", "recipe_blooper"] +CreativityLevel = Literal[1, 2, 3, 4] + +_VALID_POST_TYPES: frozenset[str] = frozenset(["plan", "recipe_success", "recipe_blooper"]) + + +def _validate_score(name: str, value: float) -> float: + if not (0.0 <= value <= 1.0): + raise ValueError(f"{name} must be between 0.0 and 1.0, got {value!r}") + return value + + +@dataclass(frozen=True) +class CommunityPost: + """Immutable snapshot of a published community post. + + Lists (dietary_tags, allergen_flags, flavor_molecules, slots) are stored as + tuples to enforce immutability. Pass lists -- they are converted in __post_init__. + """ + + # Identity + slug: str + pseudonym: str + post_type: PostType + published: datetime + title: str + + # Optional content + description: str | None + photo_url: str | None + + # Plan slots -- list[dict] for post_type="plan" + slots: tuple + + # Recipe result fields -- for post_type="recipe_success" | "recipe_blooper" + recipe_id: int | None + recipe_name: str | None + level: CreativityLevel | None + outcome_notes: str | None + + # Element snapshot + seasoning_score: float + richness_score: float + brightness_score: float + depth_score: float + aroma_score: float + structure_score: float + texture_profile: str + + # Dietary/allergen/flavor + dietary_tags: tuple + allergen_flags: tuple + flavor_molecules: tuple + + # USDA FDC (Food Data Central) macros (optional -- may not be available for all recipes) + fat_pct: float | None + protein_pct: float | None + moisture_pct: float | None + + def __new__(cls, **kwargs): + # Convert lists to tuples before frozen dataclass assignment + for key in ("slots", "dietary_tags", "allergen_flags", "flavor_molecules"): + if key in kwargs and isinstance(kwargs[key], list): + kwargs[key] = tuple(kwargs[key]) + return object.__new__(cls) + + def __init__(self, **kwargs): + # Convert lists to tuples + for key in ("slots", "dietary_tags", "allergen_flags", "flavor_molecules"): + if key in kwargs and isinstance(kwargs[key], list): + kwargs[key] = tuple(kwargs[key]) + for f in self.__dataclass_fields__: + object.__setattr__(self, f, kwargs[f]) + self.__post_init__() + + def __post_init__(self) -> None: + if self.post_type not in _VALID_POST_TYPES: + raise ValueError( + f"post_type must be one of {sorted(_VALID_POST_TYPES)}, got {self.post_type!r}" + ) + for score_name in ( + "seasoning_score", "richness_score", "brightness_score", + "depth_score", "aroma_score", "structure_score", + ): + _validate_score(score_name, getattr(self, score_name)) diff --git a/circuitforge_core/community/store.py b/circuitforge_core/community/store.py new file mode 100644 index 0000000..140563e --- /dev/null +++ b/circuitforge_core/community/store.py @@ -0,0 +1,7 @@ +# circuitforge_core/community/store.py +# MIT License +# Stub — implemented in full by Task 5 + + +class SharedStore: + pass diff --git a/tests/community/__init__.py b/tests/community/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/community/test_models.py b/tests/community/test_models.py new file mode 100644 index 0000000..46db028 --- /dev/null +++ b/tests/community/test_models.py @@ -0,0 +1,94 @@ +# tests/community/test_models.py +import pytest +from datetime import datetime, timezone +from circuitforge_core.community.models import CommunityPost + + +def make_post(**kwargs) -> CommunityPost: + defaults = dict( + slug="kiwi-plan-test-2026-04-12-pasta-week", + pseudonym="PastaWitch", + post_type="plan", + published=datetime(2026, 4, 12, 12, 0, 0, tzinfo=timezone.utc), + title="Pasta Week", + description="Seven days of carbs", + photo_url=None, + slots=[{"day": 0, "meal_type": "dinner", "recipe_id": 1, "recipe_name": "Spaghetti"}], + recipe_id=None, + recipe_name=None, + level=None, + outcome_notes=None, + seasoning_score=0.7, + richness_score=0.6, + brightness_score=0.3, + depth_score=0.5, + aroma_score=0.4, + structure_score=0.8, + texture_profile="chewy", + dietary_tags=["vegetarian"], + allergen_flags=["gluten"], + flavor_molecules=[1234, 5678], + fat_pct=12.5, + protein_pct=10.0, + moisture_pct=55.0, + ) + defaults.update(kwargs) + return CommunityPost(**defaults) + + +def test_community_post_immutable(): + post = make_post() + with pytest.raises((AttributeError, TypeError)): + post.title = "changed" # type: ignore + + +def test_community_post_slug_uri_compatible(): + post = make_post(slug="kiwi-plan-test-2026-04-12-pasta-week") + assert " " not in post.slug + assert post.slug == post.slug.lower() + + +def test_community_post_type_valid(): + make_post(post_type="plan") + make_post(post_type="recipe_success") + make_post(post_type="recipe_blooper") + + +def test_community_post_type_invalid(): + with pytest.raises(ValueError): + make_post(post_type="garbage") + + +def test_community_post_scores_range(): + post = make_post(seasoning_score=1.0, richness_score=0.0) + assert 0.0 <= post.seasoning_score <= 1.0 + assert 0.0 <= post.richness_score <= 1.0 + + +def test_community_post_scores_out_of_range(): + with pytest.raises(ValueError): + make_post(seasoning_score=1.5) + with pytest.raises(ValueError): + make_post(richness_score=-0.1) + + +def test_community_post_dietary_tags_immutable(): + post = make_post(dietary_tags=["vegan"]) + assert isinstance(post.dietary_tags, tuple) + + +def test_community_post_allergen_flags_immutable(): + post = make_post(allergen_flags=["nuts", "dairy"]) + assert isinstance(post.allergen_flags, tuple) + + +def test_community_post_flavor_molecules_immutable(): + post = make_post(flavor_molecules=[1, 2, 3]) + assert isinstance(post.flavor_molecules, tuple) + + +def test_community_post_optional_fields_none(): + post = make_post(photo_url=None, recipe_id=None, fat_pct=None) + assert post.photo_url is None + assert post.recipe_id is None + assert post.fat_pct is None