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:
285
django/src/djarea/tests/test_shapes.py
Normal file
285
django/src/djarea/tests/test_shapes.py
Normal 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, [])
|
||||
Reference in New Issue
Block a user