Add shapes tests and fix django-readers as core dependency

25 tests covering Shape, Diff, and NestedDiff:
- Shape metaclass: model resolution, field extraction, nested detection, spec/pair building
- Query: list, filter, nested relations, empty results, Pydantic serialization
- Diff (new): detects all fields as changed
- Diff (existing): no changes, single field, multiple fields, nonexistent ID
- Diff (nested): created, updated, deleted, combined, nonexistent relation

Fixes:
- django-readers moved from optional to core dependency
- Shape import lazy-loaded via __getattr__ (django_readers imports
  contenttypes which can't happen during apps.populate())
- Added Author, Book, Tag test models

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 02:16:36 -04:00
parent a726fd6863
commit 9516c05f75
4 changed files with 327 additions and 5 deletions

View File

@@ -89,10 +89,8 @@ from . import setup
from .channels import ReactChannel
from .channels import register as register_channel
from .client import ComposedContext, ServerFunction, client, compose
try:
from .shapes import Shape
except ImportError:
pass # django-readers not installed
# Shape is lazy-loaded via __getattr__ because django_readers
# imports contenttypes, which can't happen during apps.populate()
from .setup import (
djarea_clients,
djarea_module,
@@ -104,11 +102,15 @@ from .setup import (
def __getattr__(name):
"""Lazy loading for urls to avoid circular imports."""
"""Lazy loading for modules that can't be imported at app load time."""
if name == "urls":
from .urls import urlpatterns as djarea_patterns
return djarea_patterns
if name == "Shape":
from .shapes import Shape
return Shape
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -0,0 +1,285 @@
"""
Tests for djarea.shapes — Pydantic API surface for Django models.
Uses real Django models (Author, Book, Tag) and real database queries.
"""
from django.test import TestCase
from djarea.shapes import Shape, Diff, NestedDiff
from tests.models import Author, Book, Tag
# =============================================================================
# Shape definitions
# =============================================================================
class BookShape(Shape[Book]):
id: int | None = None
title: str
pages: int
class AuthorShape(Shape[Author]):
id: int | None = None
name: str
bio: str
books: list[BookShape] = []
class FlatAuthorShape(Shape[Author]):
"""Author without nested books."""
id: int | None = None
name: str
# =============================================================================
# Shape class construction
# =============================================================================
class ShapeMetaTests(TestCase):
"""Shape __init_subclass__ correctly extracts metadata."""
def test_model_resolved(self):
self.assertEqual(AuthorShape._model, Author)
self.assertEqual(BookShape._model, Book)
def test_field_names_extracted(self):
self.assertEqual(BookShape._field_names, ["id", "title", "pages"])
def test_nested_detected(self):
self.assertIn("books", AuthorShape._nested)
self.assertEqual(AuthorShape._nested["books"], BookShape)
def test_flat_shape_has_no_nested(self):
self.assertEqual(FlatAuthorShape._nested, {})
def test_spec_built(self):
self.assertIsInstance(BookShape._spec, list)
self.assertIn("title", BookShape._spec)
self.assertIn("pages", BookShape._spec)
def test_pair_built(self):
self.assertIsNotNone(BookShape._pair)
self.assertEqual(len(BookShape._pair), 2) # (prepare, project)
# =============================================================================
# Querying
# =============================================================================
class ShapeQueryTests(TestCase):
"""Shape.query() runs real ORM queries and returns typed results."""
def setUp(self):
self.author = Author.objects.create(name="Alice", bio="Writer")
self.book1 = Book.objects.create(title="Book One", pages=100, author=self.author)
self.book2 = Book.objects.create(title="Book Two", pages=200, author=self.author)
def test_query_returns_list_of_shapes(self):
results = BookShape.query()
self.assertEqual(len(results), 2)
self.assertIsInstance(results[0], BookShape)
def test_query_field_values(self):
results = BookShape.query()
titles = {b.title for b in results}
self.assertEqual(titles, {"Book One", "Book Two"})
def test_query_with_filter(self):
results = BookShape.query(Book.objects.filter(pages__gte=150))
self.assertEqual(len(results), 1)
self.assertEqual(results[0].title, "Book Two")
self.assertEqual(results[0].pages, 200)
def test_query_returns_ids(self):
results = BookShape.query()
for book in results:
self.assertIsNotNone(book.id)
self.assertIsInstance(book.id, int)
def test_flat_author_query(self):
results = FlatAuthorShape.query()
self.assertEqual(len(results), 1)
self.assertEqual(results[0].name, "Alice")
def test_nested_query(self):
results = AuthorShape.query()
self.assertEqual(len(results), 1)
author = results[0]
self.assertEqual(author.name, "Alice")
self.assertEqual(len(author.books), 2)
self.assertIsInstance(author.books[0], BookShape)
titles = {b.title for b in author.books}
self.assertEqual(titles, {"Book One", "Book Two"})
def test_empty_query(self):
results = BookShape.query(Book.objects.none())
self.assertEqual(results, [])
def test_query_returns_pydantic_models(self):
results = BookShape.query()
book = results[0]
# Can serialize to dict
d = book.model_dump()
self.assertIn("title", d)
self.assertIn("pages", d)
self.assertIn("id", d)
# =============================================================================
# Diff
# =============================================================================
class DiffNewTests(TestCase):
"""Diff for new objects (no id)."""
def test_new_object_is_new(self):
shape = BookShape(title="New Book", pages=50)
diff = shape.diff()
self.assertTrue(diff.is_new)
self.assertEqual(diff.changed["title"], "New Book")
self.assertEqual(diff.changed["pages"], 50)
self.assertNotIn("id", diff.changed)
class DiffExistingTests(TestCase):
"""Diff for existing objects detects changes."""
def setUp(self):
self.author = Author.objects.create(name="Alice", bio="Writer")
self.book = Book.objects.create(title="Original", pages=100, author=self.author)
def test_no_changes(self):
shape = BookShape(id=self.book.id, title="Original", pages=100)
diff = shape.diff()
self.assertFalse(diff.is_new)
self.assertEqual(diff.changed, {})
def test_title_changed(self):
shape = BookShape(id=self.book.id, title="Updated", pages=100)
diff = shape.diff()
self.assertFalse(diff.is_new)
self.assertEqual(diff.changed, {"title": "Updated"})
def test_multiple_changes(self):
shape = BookShape(id=self.book.id, title="Updated", pages=999)
diff = shape.diff()
self.assertEqual(diff.changed, {"title": "Updated", "pages": 999})
def test_nonexistent_id_raises(self):
shape = BookShape(id=99999, title="Ghost", pages=0)
with self.assertRaises(Book.DoesNotExist):
shape.diff()
class DiffNestedTests(TestCase):
"""Diff for shapes with nested relations."""
def setUp(self):
self.author = Author.objects.create(name="Alice", bio="Writer")
self.book1 = Book.objects.create(title="Book One", pages=100, author=self.author)
self.book2 = Book.objects.create(title="Book Two", pages=200, author=self.author)
def test_nested_no_changes(self):
shape = AuthorShape(
id=self.author.id,
name="Alice",
bio="Writer",
books=[
BookShape(id=self.book1.id, title="Book One", pages=100),
BookShape(id=self.book2.id, title="Book Two", pages=200),
],
)
diff = shape.diff()
self.assertFalse(diff.is_new)
self.assertEqual(diff.changed, {})
self.assertEqual(diff.books.created, [])
self.assertEqual(diff.books.updated, [])
self.assertEqual(diff.books.deleted, [])
def test_nested_created(self):
shape = AuthorShape(
id=self.author.id,
name="Alice",
bio="Writer",
books=[
BookShape(id=self.book1.id, title="Book One", pages=100),
BookShape(id=self.book2.id, title="Book Two", pages=200),
BookShape(title="New Book", pages=50), # no id = new
],
)
diff = shape.diff()
self.assertEqual(len(diff.books.created), 1)
self.assertEqual(diff.books.created[0].title, "New Book")
def test_nested_updated(self):
shape = AuthorShape(
id=self.author.id,
name="Alice",
bio="Writer",
books=[
BookShape(id=self.book1.id, title="Updated Title", pages=100),
BookShape(id=self.book2.id, title="Book Two", pages=200),
],
)
diff = shape.diff()
self.assertEqual(len(diff.books.updated), 1)
self.assertEqual(diff.books.updated[0].title, "Updated Title")
def test_nested_deleted(self):
shape = AuthorShape(
id=self.author.id,
name="Alice",
bio="Writer",
books=[
BookShape(id=self.book1.id, title="Book One", pages=100),
# book2 missing = deleted
],
)
diff = shape.diff()
self.assertEqual(diff.books.deleted, [self.book2.id])
def test_nested_combined(self):
"""Create, update, and delete in one diff."""
shape = AuthorShape(
id=self.author.id,
name="Alice",
bio="Writer",
books=[
BookShape(id=self.book1.id, title="Renamed", pages=100), # updated
BookShape(title="Brand New", pages=10), # created
# book2 missing = deleted
],
)
diff = shape.diff()
self.assertEqual(len(diff.books.created), 1)
self.assertEqual(len(diff.books.updated), 1)
self.assertEqual(diff.books.deleted, [self.book2.id])
def test_accessing_nonexistent_nested_returns_empty(self):
shape = BookShape(title="Simple", pages=10)
diff = shape.diff()
# BookShape has no nested relations
empty = diff.nonexistent_relation
self.assertIsInstance(empty, NestedDiff)
self.assertEqual(empty.created, [])
self.assertEqual(empty.updated, [])
self.assertEqual(empty.deleted, [])