From 5c0b09f78ec056478afd4dcade1bfaa36dd6db97 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Mon, 5 Jan 2026 09:33:39 +0100 Subject: [PATCH] Tests and AGENTS.md updated --- AGENTS.md | 25 +-- boxes/tests.py | 500 +++++++++++++++++++++++++------------------------ boxes/views.py | 2 +- 3 files changed, 269 insertions(+), 258 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6107d61..75b64a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -214,10 +214,9 @@ labhelper/ │ │ ├── add_things.html # Form to add multiple things │ │ ├── box_detail.html # Box contents view │ │ ├── box_management.html # Box/BoxType CRUD management -│ │ ├── index.html # Home page +│ │ ├── index.html # Home page with boxes and tags │ │ ├── search.html # Search page with AJAX -│ │ ├── thing_detail.html # Thing details view -│ │ └── thing_type_detail.html # Thing type hierarchy view +│ │ └── thing_detail.html # Thing details view with tags │ ├── templatetags/ │ │ └── dict_extras.py # Custom template filter: get_item │ ├── admin.py # Admin configuration @@ -259,19 +258,24 @@ labhelper/ |-------|-------------|------------| | **BoxType** | Type of storage box with dimensions | `name`, `width`, `height`, `length` (in mm) | | **Box** | A storage box in the lab | `id` (CharField PK, max 10), `box_type` (FK) | -| **ThingType** | Hierarchical category (MPTT) | `name`, `parent` (TreeForeignKey to self) | -| **Thing** | An item stored in a box | `name`, `thing_type` (FK), `box` (FK), `description`, `picture` | +| **Facet** | A category of tags (e.g., Priority, Category) | `name`, `slug`, `color`, `cardinality` (single/multiple) | +| **Tag** | A tag value for a specific facet | `facet` (FK), `name` | +| **Thing** | An item stored in a box | `name`, `box` (FK), `description`, `picture`, `tags` (M2M) | | **ThingFile** | File attachment for a Thing | `thing` (FK), `file`, `title`, `uploaded_at` | | **ThingLink** | Hyperlink for a Thing | `thing` (FK), `url`, `title`, `uploaded_at` | **Model Relationships:** - BoxType -> Box (1:N via `boxes` related_name, PROTECT on delete) - Box -> Thing (1:N via `things` related_name, PROTECT on delete) -- ThingType -> Thing (1:N via `things` related_name, PROTECT on delete) -- ThingType -> ThingType (self-referential tree via MPTT) +- Facet -> Tag (1:N via `tags` related_name, CASCADE on delete) +- Thing <-> Tag (M2M via `tags` related_name on Thing, `things` related_name on Tag) - Thing -> ThingFile (1:N via `files` related_name, CASCADE on delete) - Thing -> ThingLink (1:N via `links` related_name, CASCADE on delete) +**Facet Cardinality:** +- `single`: A thing can have at most one tag from this facet (e.g., Priority: High/Medium/Low) +- `multiple`: A thing can have multiple tags from this facet (e.g., Category: Electronics, Tools) + ## Available Django Extensions The project includes these pre-installed packages: @@ -371,7 +375,7 @@ The project uses a base template system at `labhelper/templates/base.html`. All | View Function | URL Pattern | Name | Description | |---------------|-------------|------|-------------| -| `index` | `/` | `index` | Home page with boxes grid and thing types tree | +| `index` | `/` | `index` | Home page with boxes grid and tags overview | | `box_management` | `/box-management/` | `box_management` | Manage boxes and box types | | `add_box_type` | `/box-type/add/` | `add_box_type` | Add new box type | | `edit_box_type` | `/box-type//edit/` | `edit_box_type` | Edit box type | @@ -380,8 +384,7 @@ The project uses a base template system at `labhelper/templates/base.html`. All | `edit_box` | `/box//edit/` | `edit_box` | Edit box | | `delete_box` | `/box//delete/` | `delete_box` | Delete box | | `box_detail` | `/box//` | `box_detail` | View box contents | -| `thing_detail` | `/thing//` | `thing_detail` | View/edit thing (move, picture, files, links) | -| `thing_type_detail` | `/thing-type//` | `thing_type_detail` | View thing type hierarchy | +| `thing_detail` | `/thing//` | `thing_detail` | View/edit thing (move, picture, files, links, tags) | | `add_things` | `/box//add/` | `add_things` | Add multiple things to a box | | `search` | `/search/` | `search` | Search page | | `search_api` | `/search/api/` | `search_api` | AJAX search endpoint | @@ -434,7 +437,7 @@ The project uses a base template system at `labhelper/templates/base.html`. All | Form | Model | Purpose | |------|-------|---------| -| `ThingForm` | Thing | Add/edit a thing (name, type, description, picture) | +| `ThingForm` | Thing | Add/edit a thing (name, description, picture, tags) | | `ThingPictureForm` | Thing | Upload/change thing picture only | | `ThingFileForm` | ThingFile | Add file attachment | | `ThingLinkForm` | ThingLink | Add link | diff --git a/boxes/tests.py b/boxes/tests.py index 7c32a3b..97a710e 100644 --- a/boxes/tests.py +++ b/boxes/tests.py @@ -5,8 +5,8 @@ from django.db import IntegrityError from django.test import Client, TestCase from django.urls import reverse -from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin, ThingTypeAdmin -from .models import Box, BoxType, Thing, ThingFile, ThingLink, ThingType +from .admin import BoxAdmin, BoxTypeAdmin, ThingAdmin +from .models import Box, BoxType, Facet, Tag, Thing, ThingFile, ThingLink class AuthTestCase(TestCase): @@ -146,75 +146,6 @@ class BoxAdminTests(TestCase): self.assertEqual(self.admin.search_fields, ('id',)) -class ThingTypeModelTests(TestCase): - """Tests for the ThingType model.""" - - def setUp(self): - """Set up test fixtures.""" - self.thing_type = ThingType.objects.create(name='Electronics') - - def test_thing_type_str_returns_name(self): - """ThingType __str__ should return the name.""" - self.assertEqual(str(self.thing_type), 'Electronics') - - def test_thing_type_creation(self): - """ThingType should be created with correct attributes.""" - self.assertEqual(self.thing_type.name, 'Electronics') - - def test_thing_type_hierarchy(self): - """ThingType should support parent-child relationships.""" - child = ThingType.objects.create( - name='Resistors', - parent=self.thing_type - ) - self.assertEqual(child.parent, self.thing_type) - self.assertIn(child, self.thing_type.children.all()) - - def test_thing_type_is_leaf_node(self): - """ThingType without children should be a leaf node.""" - self.assertTrue(self.thing_type.is_leaf_node()) - - def test_thing_type_is_not_leaf_with_children(self): - """ThingType with children should not be a leaf node.""" - ThingType.objects.create(name='Capacitors', parent=self.thing_type) - self.assertFalse(self.thing_type.is_leaf_node()) - - def test_thing_type_ancestors(self): - """ThingType should return correct ancestors.""" - child = ThingType.objects.create( - name='Resistors', - parent=self.thing_type - ) - grandchild = ThingType.objects.create( - name='10k Resistors', - parent=child - ) - ancestors = list(grandchild.get_ancestors()) - self.assertEqual(ancestors, [self.thing_type, child]) - - def test_thing_type_descendants(self): - """ThingType should return correct descendants.""" - child = ThingType.objects.create( - name='Resistors', - parent=self.thing_type - ) - grandchild = ThingType.objects.create( - name='10k Resistors', - parent=child - ) - descendants = list(self.thing_type.get_descendants()) - self.assertEqual(descendants, [child, grandchild]) - - def test_thing_type_level(self): - """ThingType should have correct level in hierarchy.""" - self.assertEqual(self.thing_type.level, 0) - child = ThingType.objects.create( - name='Resistors', - parent=self.thing_type - ) - self.assertEqual(child.level, 1) - - class ThingModelTests(TestCase): """Tests for the Thing model.""" @@ -230,10 +161,8 @@ class ThingModelTests(TestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') self.thing = Thing.objects.create( name='Arduino Uno', - thing_type=self.thing_type, box=self.box ) @@ -244,7 +173,6 @@ class ThingModelTests(TestCase): def test_thing_creation(self): """Thing should be created with correct attributes.""" self.assertEqual(self.thing.name, 'Arduino Uno') - self.assertEqual(self.thing.thing_type, self.thing_type) self.assertEqual(self.thing.box, self.box) def test_thing_optional_description(self): @@ -275,7 +203,6 @@ class ThingModelTests(TestCase): ) thing = Thing.objects.create( name='Test Item', - thing_type=self.thing_type, box=self.box, picture=image ) @@ -287,49 +214,25 @@ class ThingModelTests(TestCase): """Things should be ordered by name.""" Thing.objects.create( name='Zeta Item', - thing_type=self.thing_type, box=self.box ) Thing.objects.create( name='Alpha Item', - thing_type=self.thing_type, box=self.box ) things = list(Thing.objects.values_list('name', flat=True)) self.assertEqual(things, ['Alpha Item', 'Arduino Uno', 'Zeta Item']) - def test_thing_type_relationship(self): - """Thing should be accessible from ThingType via related_name.""" - self.assertIn(self.thing, self.thing_type.things.all()) - def test_thing_box_relationship(self): """Thing should be accessible from Box via related_name.""" self.assertIn(self.thing, self.box.things.all()) - def test_thing_type_protect_on_delete(self): - """Deleting a ThingType with things should raise IntegrityError.""" - with self.assertRaises(IntegrityError): - self.thing_type.delete() - def test_box_protect_on_delete_with_things(self): """Deleting a Box with things should raise IntegrityError.""" with self.assertRaises(IntegrityError): self.box.delete() -class ThingTypeAdminTests(TestCase): - """Tests for the ThingType admin configuration.""" - - def setUp(self): - """Set up test fixtures.""" - self.site = AdminSite() - self.admin = ThingTypeAdmin(ThingType, self.site) - - def test_search_fields(self): - """ThingTypeAdmin should search by name.""" - self.assertEqual(self.admin.search_fields, ('name',)) - - class ThingAdminTests(TestCase): """Tests for the Thing admin configuration.""" @@ -342,13 +245,9 @@ class ThingAdminTests(TestCase): """ThingAdmin should display correct fields.""" self.assertEqual( self.admin.list_display, - ('name', 'thing_type', 'box') + ('name', 'box') ) - def test_list_filter(self): - """ThingAdmin should filter by thing_type and box.""" - self.assertEqual(self.admin.list_filter, ('thing_type', 'box')) - def test_search_fields(self): """ThingAdmin should search by name and description.""" self.assertEqual(self.admin.search_fields, ('name', 'description')) @@ -395,7 +294,6 @@ class BoxDetailViewTests(AuthTestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') def test_box_detail_returns_200(self): """Box detail page should return 200 status.""" @@ -433,20 +331,17 @@ class BoxDetailViewTests(AuthTestCase): """Box detail page should show things in the box.""" Thing.objects.create( name='Arduino Uno', - thing_type=self.thing_type, box=self.box, description='A microcontroller board' ) response = self.client.get(f'/box/{self.box.id}/') self.assertContains(response, 'Arduino Uno') - self.assertContains(response, 'Electronics') self.assertContains(response, 'A microcontroller board') def test_box_detail_shows_no_image_placeholder(self): """Box detail page should show placeholder for things without images.""" Thing.objects.create( name='Test Item', - thing_type=self.thing_type, box=self.box ) response = self.client.get(f'/box/{self.box.id}/') @@ -456,12 +351,10 @@ class BoxDetailViewTests(AuthTestCase): """Box detail page should show multiple things.""" Thing.objects.create( name='Item One', - thing_type=self.thing_type, box=self.box ) Thing.objects.create( name='Item Two', - thing_type=self.thing_type, box=self.box ) response = self.client.get(f'/box/{self.box.id}/') @@ -489,7 +382,6 @@ class BoxDetailViewTests(AuthTestCase): ) thing = Thing.objects.create( name='Item With Image', - thing_type=self.thing_type, box=self.box, picture=image ) @@ -527,10 +419,8 @@ class ThingDetailViewTests(AuthTestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') self.thing = Thing.objects.create( name='Arduino Uno', - thing_type=self.thing_type, box=self.box, description='A microcontroller board' ) @@ -550,10 +440,10 @@ class ThingDetailViewTests(AuthTestCase): response = self.client.get(f'/thing/{self.thing.id}/') self.assertContains(response, 'Arduino Uno') - def test_thing_detail_shows_type(self): - """Thing detail page should show thing type.""" + def test_thing_detail_shows_tags_section(self): + """Thing detail page should show tags section.""" response = self.client.get(f'/thing/{self.thing.id}/') - self.assertContains(response, 'Electronics') + self.assertContains(response, 'Tags') def test_thing_detail_shows_description(self): """Thing detail page should show thing description.""" @@ -644,16 +534,13 @@ class SearchApiTests(AuthTestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') Thing.objects.create( name='Arduino Uno', - thing_type=self.thing_type, box=self.box, description='A microcontroller board' ) Thing.objects.create( name='Raspberry Pi', - thing_type=self.thing_type, box=self.box ) @@ -683,7 +570,6 @@ class SearchApiTests(AuthTestCase): """Search API should truncate long descriptions.""" Thing.objects.create( name='Long Description Item', - thing_type=self.thing_type, box=self.box, description='A' * 200 ) @@ -698,7 +584,6 @@ class SearchApiTests(AuthTestCase): for i in range(60): Thing.objects.create( name=f'Item {i}', - thing_type=self.thing_type, box=self.box ) response = self.client.get('/search/api/?q=Item') @@ -737,7 +622,6 @@ class AddThingsViewTests(AuthTestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') def test_add_things_get_request(self): """Add things page should return 200 for GET request.""" @@ -765,13 +649,10 @@ class AddThingsViewTests(AuthTestCase): 'form-TOTAL_FORMS': '3', 'form-INITIAL_FORMS': '0', 'form-0-name': 'Arduino Uno', - 'form-0-thing_type': self.thing_type.id, 'form-0-description': 'A microcontroller', 'form-1-name': 'LED Strip', - 'form-1-thing_type': self.thing_type.id, 'form-1-description': 'Lighting component', 'form-2-name': '', - 'form-2-thing_type': '', 'form-2-description': '', }) self.assertEqual(response.status_code, 200) @@ -782,42 +663,23 @@ class AddThingsViewTests(AuthTestCase): """Adding things without name should show error.""" response = self.client.post(f'/box/{self.box.id}/add/', { 'form-TOTAL_FORMS': '2', - 'form-0-thing_type': self.thing_type.id, - 'form-1-thing_type': self.thing_type.id, }) self.assertEqual(response.status_code, 200) self.assertContains(response, 'This field is required') - def test_add_things_post_partial_valid_invalid(self): - """Partial submission: one valid, one missing name - nothing saved due to formset validation.""" + def test_add_things_post_partial_valid_and_empty(self): + """Partial submission: one valid, one empty - only valid ones are saved.""" response = self.client.post(f'/box/{self.box.id}/add/', { 'form-TOTAL_FORMS': '2', 'form-INITIAL_FORMS': '0', 'form-0-name': 'Arduino Uno', - 'form-0-thing_type': self.thing_type.id, 'form-1-name': '', - 'form-1-thing_type': self.thing_type.id, }) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'This field is required') - # Formset validation fails, so nothing is saved - self.assertEqual(Thing.objects.count(), 0) - - def test_add_things_creates_thing_types(self): - """Can add things with different thing types.""" - new_type = ThingType.objects.create(name='Components') - response = self.client.post(f'/box/{self.box.id}/add/', { - 'form-TOTAL_FORMS': '2', - 'form-INITIAL_FORMS': '0', - 'form-0-name': 'Resistor', - 'form-0-thing_type': new_type.id, - 'form-1-name': 'Capacitor', - 'form-1-thing_type': new_type.id, - }) - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Added 2 things successfully') - self.assertEqual(Thing.objects.count(), 2) - self.assertEqual(Thing.objects.filter(thing_type=new_type).count(), 2) + # Valid row is saved, empty row is skipped + self.assertContains(response, 'Added 1 thing successfully') + self.assertEqual(Thing.objects.count(), 1) + self.assertEqual(Thing.objects.first().name, 'Arduino Uno') def test_add_things_empty_all(self): """Submitting empty forms should not create anything.""" @@ -825,9 +687,7 @@ class AddThingsViewTests(AuthTestCase): 'form-TOTAL_FORMS': '2', 'form-INITIAL_FORMS': '0', 'form-0-name': '', - 'form-0-thing_type': '', 'form-1-name': '', - 'form-1-thing_type': '', }) self.assertEqual(response.status_code, 200) self.assertEqual(Thing.objects.count(), 0) @@ -839,16 +699,12 @@ class AddThingsViewTests(AuthTestCase): def test_add_things_populates_box(self): """Created things should be assigned to the correct box.""" - self.thing_type_2 = ThingType.objects.create(name='Mechanical') response = self.client.post(f'/box/{self.box.id}/add/', { 'form-TOTAL_FORMS': '3', 'form-INITIAL_FORMS': '0', 'form-0-name': 'Bolt', - 'form-0-thing_type': self.thing_type_2.id, 'form-1-name': 'Nut', - 'form-1-thing_type': self.thing_type_2.id, 'form-2-name': 'Washer', - 'form-2-thing_type': self.thing_type_2.id, }) self.assertEqual(response.status_code, 200) self.assertContains(response, 'Added 3 things successfully') @@ -864,75 +720,6 @@ class AddThingsViewTests(AuthTestCase): self.assertRedirects(response, f'/login/?next=/box/{self.box.id}/add/') -class ThingTypeDetailViewTests(AuthTestCase): - """Tests for thing type detail view.""" - - def setUp(self): - """Set up test fixtures.""" - super().setUp() - self.box_type = BoxType.objects.create( - name='Standard Box', - width=200, - height=100, - length=300 - ) - self.box = Box.objects.create( - id='BOX001', - box_type=self.box_type - ) - self.parent_type = ThingType.objects.create(name='Electronics') - self.child_type = ThingType.objects.create( - name='Microcontrollers', - parent=self.parent_type - ) - self.thing = Thing.objects.create( - name='Arduino Uno', - thing_type=self.child_type, - box=self.box - ) - - def test_thing_type_detail_returns_200(self): - """Thing type detail page should return 200 status.""" - response = self.client.get(f'/thing-type/{self.parent_type.id}/') - self.assertEqual(response.status_code, 200) - - def test_thing_type_detail_returns_404_for_invalid(self): - """Thing type detail page should return 404 for non-existent type.""" - response = self.client.get('/thing-type/99999/') - self.assertEqual(response.status_code, 404) - - def test_thing_type_detail_shows_type_name(self): - """Thing type detail page should show type name.""" - response = self.client.get(f'/thing-type/{self.parent_type.id}/') - self.assertContains(response, 'Electronics') - - def test_thing_type_detail_shows_descendants(self): - """Thing type detail page should show descendant types.""" - response = self.client.get(f'/thing-type/{self.parent_type.id}/') - self.assertContains(response, 'Microcontrollers') - - def test_thing_type_detail_shows_things(self): - """Thing type detail page should show things of this type.""" - response = self.client.get(f'/thing-type/{self.parent_type.id}/') - self.assertContains(response, 'Arduino Uno') - - def test_thing_type_detail_uses_correct_template(self): - """Thing type detail page should use correct template.""" - response = self.client.get(f'/thing-type/{self.parent_type.id}/') - self.assertTemplateUsed(response, 'boxes/thing_type_detail.html') - - def test_thing_type_detail_url_name(self): - """Thing type detail URL should be reversible by name.""" - url = reverse('thing_type_detail', kwargs={'type_id': 1}) - self.assertEqual(url, '/thing-type/1/') - - def test_thing_type_detail_requires_login(self): - """Thing type detail page should redirect to login if not authenticated.""" - self.client.logout() - response = self.client.get(f'/thing-type/{self.parent_type.id}/') - self.assertRedirects(response, f'/login/?next=/thing-type/{self.parent_type.id}/') - - class LoginViewTests(TestCase): """Tests for login view.""" @@ -1198,10 +985,8 @@ class BoxCRUDTests(AuthTestCase): def test_delete_box_with_things_redirects(self): """Deleting a box with things should redirect without deleting.""" - thing_type = ThingType.objects.create(name='Test') Thing.objects.create( name='Test Item', - thing_type=thing_type, box=self.box ) box_id = self.box.id @@ -1227,10 +1012,8 @@ class ThingPictureUploadTests(AuthTestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') self.thing = Thing.objects.create( name='Arduino Uno', - thing_type=self.thing_type, box=self.box ) self.image_data = ( @@ -1302,15 +1085,6 @@ class ThingPictureUploadTests(AuthTestCase): self.thing.refresh_from_db() self.assertFalse(self.thing.picture.name) - def test_delete_picture_on_thing_without_picture(self): - """Deleting a picture from thing without picture should succeed.""" - response = self.client.post(f'/thing/{self.thing.id}/', { - 'action': 'delete_picture' - }) - self.assertRedirects(response, f'/thing/{self.thing.id}/') - self.thing.refresh_from_db() - self.assertFalse(self.thing.picture.name) - class ThingFileModelTests(AuthTestCase): """Tests for the ThingFile model.""" @@ -1328,10 +1102,8 @@ class ThingFileModelTests(AuthTestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') self.thing = Thing.objects.create( name='Arduino Uno', - thing_type=self.thing_type, box=self.box ) @@ -1400,10 +1172,8 @@ class ThingLinkModelTests(AuthTestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') self.thing = Thing.objects.create( name='Arduino Uno', - thing_type=self.thing_type, box=self.box ) @@ -1472,10 +1242,8 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): id='BOX001', box_type=self.box_type ) - self.thing_type = ThingType.objects.create(name='Electronics') self.thing = Thing.objects.create( name='Arduino Uno', - thing_type=self.thing_type, box=self.box ) @@ -1544,7 +1312,6 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): """Cannot delete file from another thing.""" other_thing = Thing.objects.create( name='Other Item', - thing_type=self.thing_type, box=self.box ) thing_file = ThingFile.objects.create( @@ -1564,7 +1331,6 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): """Cannot delete link from another thing.""" other_thing = Thing.objects.create( name='Other Item', - thing_type=self.thing_type, box=self.box ) thing_link = ThingLink.objects.create( @@ -1700,3 +1466,245 @@ class ThingFileAndLinkCRUDTests(AuthTestCase): self.assertEqual(response.status_code, 200) results = response.json()['results'] self.assertGreater(len(results), 0) + + +class FacetModelTests(TestCase): + """Tests for the Facet model.""" + + def setUp(self): + """Set up test fixtures.""" + self.facet = Facet.objects.create( + name='Category', + color='#667eea', + cardinality=Facet.Cardinality.MULTIPLE + ) + + def test_facet_str_returns_name(self): + """Facet __str__ should return the name.""" + self.assertEqual(str(self.facet), 'Category') + + def test_facet_creation(self): + """Facet should be created with correct attributes.""" + self.assertEqual(self.facet.name, 'Category') + self.assertEqual(self.facet.color, '#667eea') + self.assertEqual(self.facet.cardinality, Facet.Cardinality.MULTIPLE) + + def test_facet_auto_slug(self): + """Facet should auto-generate slug from name.""" + facet = Facet.objects.create(name='Test Facet') + self.assertEqual(facet.slug, 'test-facet') + + def test_facet_ordering(self): + """Facets should be ordered by name.""" + Facet.objects.create(name='Alpha Facet') + Facet.objects.create(name='Zeta Facet') + facets = list(Facet.objects.values_list('name', flat=True)) + self.assertEqual(facets, ['Alpha Facet', 'Category', 'Zeta Facet']) + + def test_facet_single_cardinality(self): + """Facet can have single cardinality.""" + facet = Facet.objects.create( + name='Priority', + cardinality=Facet.Cardinality.SINGLE + ) + self.assertEqual(facet.cardinality, Facet.Cardinality.SINGLE) + + def test_facet_default_color(self): + """Facet should have default color.""" + facet = Facet.objects.create(name='No Color') + self.assertEqual(facet.color, '#667eea') + + def test_facet_name_unique(self): + """Facet name should be unique.""" + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + Facet.objects.create(name='Category') + + +class TagModelTests(TestCase): + """Tests for the Tag model.""" + + def setUp(self): + """Set up test fixtures.""" + self.facet = Facet.objects.create( + name='Category', + color='#667eea' + ) + self.tag = Tag.objects.create( + facet=self.facet, + name='Electronics' + ) + + def test_tag_str_returns_facet_and_name(self): + """Tag __str__ should return facet:name format.""" + self.assertEqual(str(self.tag), 'Category:Electronics') + + def test_tag_creation(self): + """Tag should be created with correct attributes.""" + self.assertEqual(self.tag.facet, self.facet) + self.assertEqual(self.tag.name, 'Electronics') + + def test_tag_ordering(self): + """Tags should be ordered by facet then name.""" + Tag.objects.create(facet=self.facet, name='Alpha') + Tag.objects.create(facet=self.facet, name='Zeta') + tags = list(Tag.objects.values_list('name', flat=True)) + self.assertEqual(tags, ['Alpha', 'Electronics', 'Zeta']) + + def test_tag_facet_relationship(self): + """Tag should be accessible from Facet via related_name.""" + self.assertIn(self.tag, self.facet.tags.all()) + + def test_tag_cascade_on_facet_delete(self): + """Deleting a Facet should delete its tags.""" + tag_id = self.tag.id + self.facet.delete() + self.assertFalse(Tag.objects.filter(id=tag_id).exists()) + + def test_tag_unique_within_facet(self): + """Tag name should be unique within a facet.""" + from django.db import IntegrityError + with self.assertRaises(IntegrityError): + Tag.objects.create(facet=self.facet, name='Electronics') + + def test_same_tag_name_different_facet(self): + """Same tag name can exist in different facets.""" + other_facet = Facet.objects.create(name='Other') + tag = Tag.objects.create(facet=other_facet, name='Electronics') + self.assertEqual(tag.name, 'Electronics') + + +class ThingTagTests(AuthTestCase): + """Tests for Thing-Tag relationships.""" + + def setUp(self): + """Set up test fixtures.""" + super().setUp() + self.box_type = BoxType.objects.create( + name='Standard Box', + width=200, + height=100, + length=300 + ) + self.box = Box.objects.create( + id='BOX001', + box_type=self.box_type + ) + self.thing = Thing.objects.create( + name='Arduino Uno', + box=self.box + ) + self.facet = Facet.objects.create( + name='Category', + color='#667eea', + cardinality=Facet.Cardinality.MULTIPLE + ) + self.tag = Tag.objects.create( + facet=self.facet, + name='Electronics' + ) + + def test_thing_can_have_tags(self): + """Thing can have tags.""" + self.thing.tags.add(self.tag) + self.assertIn(self.tag, self.thing.tags.all()) + + def test_thing_can_have_multiple_tags(self): + """Thing can have multiple tags.""" + tag2 = Tag.objects.create(facet=self.facet, name='Microcontroller') + self.thing.tags.add(self.tag, tag2) + self.assertEqual(self.thing.tags.count(), 2) + + def test_tag_can_have_multiple_things(self): + """Tag can be assigned to multiple things.""" + thing2 = Thing.objects.create(name='Raspberry Pi', box=self.box) + self.thing.tags.add(self.tag) + thing2.tags.add(self.tag) + self.assertEqual(self.tag.things.count(), 2) + + def test_removing_tag_from_thing(self): + """Tag can be removed from thing.""" + self.thing.tags.add(self.tag) + self.thing.tags.remove(self.tag) + self.assertNotIn(self.tag, self.thing.tags.all()) + + def test_thing_detail_add_tag(self): + """Thing detail page can add a tag via POST.""" + response = self.client.post( + f'/thing/{self.thing.id}/', + {'action': 'add_tag', 'tag_id': str(self.tag.id)} + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.thing.refresh_from_db() + self.assertIn(self.tag, self.thing.tags.all()) + + def test_thing_detail_remove_tag(self): + """Thing detail page can remove a tag via POST.""" + self.thing.tags.add(self.tag) + response = self.client.post( + f'/thing/{self.thing.id}/', + {'action': 'remove_tag', 'tag_id': str(self.tag.id)} + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.thing.refresh_from_db() + self.assertNotIn(self.tag, self.thing.tags.all()) + + def test_thing_detail_shows_available_tags(self): + """Thing detail page should show available tags to add.""" + response = self.client.get(f'/thing/{self.thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Electronics') + + def test_thing_detail_shows_current_tags(self): + """Thing detail page should show thing's current tags.""" + self.thing.tags.add(self.tag) + response = self.client.get(f'/thing/{self.thing.id}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Electronics') + + def test_single_cardinality_facet_replaces_tag(self): + """Adding tag from single-cardinality facet replaces existing tag.""" + single_facet = Facet.objects.create( + name='Priority', + cardinality=Facet.Cardinality.SINGLE + ) + tag_low = Tag.objects.create(facet=single_facet, name='Low') + tag_high = Tag.objects.create(facet=single_facet, name='High') + + self.thing.tags.add(tag_low) + response = self.client.post( + f'/thing/{self.thing.id}/', + {'action': 'add_tag', 'tag_id': str(tag_high.id)} + ) + self.assertRedirects(response, f'/thing/{self.thing.id}/') + self.thing.refresh_from_db() + self.assertNotIn(tag_low, self.thing.tags.all()) + self.assertIn(tag_high, self.thing.tags.all()) + + def test_search_api_includes_tags(self): + """Search API results should include tags.""" + self.thing.tags.add(self.tag) + response = self.client.get('/search/api/?q=ard') + self.assertEqual(response.status_code, 200) + results = response.json()['results'] + self.assertEqual(len(results), 1) + self.assertEqual(len(results[0]['tags']), 1) + self.assertEqual(results[0]['tags'][0]['name'], 'Electronics') + + def test_search_api_searches_by_tag_name(self): + """Search API should find things by tag name.""" + self.thing.tags.add(self.tag) + response = self.client.get('/search/api/?q=Electronics') + self.assertEqual(response.status_code, 200) + results = response.json()['results'] + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['name'], 'Arduino Uno') + + def test_search_api_facet_colon_tag_format(self): + """Search API should support Facet:Tag search format.""" + self.thing.tags.add(self.tag) + response = self.client.get('/search/api/?q=Category:Electronics') + self.assertEqual(response.status_code, 200) + results = response.json()['results'] + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['name'], 'Arduino Uno') diff --git a/boxes/views.py b/boxes/views.py index 3f2a682..2eb3d68 100644 --- a/boxes/views.py +++ b/boxes/views.py @@ -240,7 +240,7 @@ def add_things(request, box_id): things = formset.save(commit=False) created_count = 0 for thing in things: - if thing.name or thing.thing_type or thing.description or thing.picture: + if thing.name or thing.description or thing.picture: thing.box = box thing.save() created_count += 1