feat(community): add CommunityPost frozen dataclass with element snapshot schema
This commit is contained in:
parent
3082318e0d
commit
2e9e3fdc4b
4 changed files with 198 additions and 2 deletions
|
|
@ -1,8 +1,16 @@
|
||||||
# circuitforge_core/community/__init__.py
|
# circuitforge_core/community/__init__.py
|
||||||
# MIT License
|
# MIT License
|
||||||
|
|
||||||
from .db import CommunityDB
|
|
||||||
from .models import CommunityPost
|
from .models import CommunityPost
|
||||||
from .store import SharedStore
|
|
||||||
|
try:
|
||||||
|
from .db import CommunityDB
|
||||||
|
except ImportError:
|
||||||
|
CommunityDB = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .store import SharedStore
|
||||||
|
except ImportError:
|
||||||
|
SharedStore = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
__all__ = ["CommunityDB", "CommunityPost", "SharedStore"]
|
__all__ = ["CommunityDB", "CommunityPost", "SharedStore"]
|
||||||
|
|
|
||||||
94
circuitforge_core/community/models.py
Normal file
94
circuitforge_core/community/models.py
Normal file
|
|
@ -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 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))
|
||||||
0
tests/community/__init__.py
Normal file
0
tests/community/__init__.py
Normal file
94
tests/community/test_models.py
Normal file
94
tests/community/test_models.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue