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
|
||||
# MIT License
|
||||
|
||||
from .db import CommunityDB
|
||||
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"]
|
||||
|
|
|
|||
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