diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a734e68 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,333 @@ +# Django Railroad Assets Manager - Agent Guidelines + +This document provides coding guidelines and command references for AI coding agents working on the Django-RAM project. + +## Project Overview + +Django Railroad Assets Manager (django-ram) is a Django 6.0+ application for managing model railroad collections with DCC++ EX integration. The project manages rolling stock, consists, metadata, books/magazines, and provides an optional REST API for DCC control. + +## Environment Setup + +### Python Requirements +- Python 3.11+ (tested on 3.13, 3.14) +- Django >= 6.0 +- Working directory: `ram/` (Django project root) +- Virtual environment recommended: `python3 -m venv venv && source venv/bin/activate` + +### Installation +```bash +pip install -r requirements.txt # Core dependencies +pip install -r requirements-dev.txt # Development tools +cd ram && python manage.py migrate # Initialize database +python manage.py createsuperuser # Create admin user +``` + +### Frontend Assets +```bash +npm install # Install clean-css-cli, terser +``` + +## Project Structure + +``` +ram/ # Django project root +├── ram/ # Core settings, URLs, base models +├── portal/ # Public-facing frontend (Bootstrap 5) +├── roster/ # Rolling stock management (main app) +├── metadata/ # Manufacturers, companies, scales, decoders +├── bookshelf/ # Books and magazines +├── consist/ # Train consists (multiple locomotives) +├── repository/ # Document repository +├── driver/ # DCC++ EX API gateway (optional, disabled by default) +└── storage/ # Runtime data (SQLite DB, media, cache) +``` + +## Build/Lint/Test Commands + +### Running the Development Server +```bash +cd ram +python manage.py runserver # Runs on http://localhost:8000 +``` + +### Database Management +```bash +python manage.py makemigrations # Create new migrations +python manage.py migrate # Apply migrations +python manage.py showmigrations # Show migration status +``` + +### Testing +```bash +# Run all tests (comprehensive test suite with 75+ tests) +python manage.py test + +# Run tests for a specific app +python manage.py test roster # Rolling stock tests +python manage.py test metadata # Metadata tests +python manage.py test bookshelf # Books/magazines tests +python manage.py test consist # Consist tests + +# Run a specific test case class +python manage.py test roster.tests.RollingStockTestCase +python manage.py test metadata.tests.ScaleTestCase + +# Run a single test method +python manage.py test roster.tests.RollingStockTestCase.test_road_number_int_extraction +python manage.py test bookshelf.tests.TocEntryTestCase.test_toc_entry_page_validation_exceeds_book + +# Run with verbosity for detailed output +python manage.py test --verbosity=2 + +# Keep test database for inspection +python manage.py test --keepdb + +# Run tests matching a pattern +python manage.py test --pattern="test_*.py" +``` + +### Linting +```bash +# Run flake8 (configured in requirements-dev.txt) +flake8 . # Lint entire project +flake8 roster/ # Lint specific app +flake8 roster/models.py # Lint specific file + +# Note: No .flake8 config exists; uses PEP 8 defaults +# Long lines use # noqa: E501 comments in settings.py +``` + +### Admin Commands +```bash +python manage.py createsuperuser # Create admin user +python manage.py purge_cache # Custom: purge cache +python manage.py loaddata # Load sample data +``` + +### Debugging & Profiling +```bash +# Use pdbpp for debugging (installed via requirements-dev.txt) +import pdb; pdb.set_trace() # Set breakpoint in code + +# Use pyinstrument for profiling +python manage.py runserver --noreload # With pyinstrument middleware +``` + +## Code Style Guidelines + +### General Python Style +- **PEP 8 compliant** - Follow standard Python style guide +- **Line length**: 79 characters preferred; 119 acceptable for complex lines +- **Long lines**: Use `# noqa: E501` comment when necessary (see settings.py) +- **Indentation**: 4 spaces (no tabs) +- **Encoding**: UTF-8 + +### Import Organization +Follow Django's import style (as seen in models.py, views.py, admin.py): + +```python +# 1. Standard library imports +import os +import re +from itertools import chain +from functools import reduce + +# 2. Related third-party imports +from django.db import models +from django.conf import settings +from django.contrib import admin +from tinymce import models as tinymce + +# 3. Local application imports +from ram.models import BaseModel, Image +from ram.utils import DeduplicatedStorage, slugify +from metadata.models import Scale, Manufacturer +``` + +**Key points:** +- Group imports by category with blank lines between +- Use `from module import specific` for commonly used items +- Avoid `import *` +- Use `as` for aliasing when needed (e.g., `tinymce.models as tinymce`) + +### Naming Conventions +- **Classes**: `PascalCase` (e.g., `RollingStock`, `BaseModel`) +- **Functions/methods**: `snake_case` (e.g., `get_items_per_page()`, `image_thumbnail()`) +- **Variables**: `snake_case` (e.g., `road_number`, `item_number_slug`) +- **Constants**: `UPPER_SNAKE_CASE` (e.g., `BASE_DIR`, `ALLOWED_HOSTS`) +- **Private methods**: Prefix with `_` (e.g., `_internal_method()`) +- **Model Meta options**: Use `verbose_name`, `verbose_name_plural`, `ordering` + +### Django Model Patterns + +```python +class MyModel(BaseModel): # Inherit from BaseModel for common fields + # Field order: relationships first, then data fields, then metadata + foreign_key = models.ForeignKey(OtherModel, on_delete=models.CASCADE) + name = models.CharField(max_length=128) + slug = models.SlugField(max_length=128, unique=True) + created = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["name"] + verbose_name = "My Model" + verbose_name_plural = "My Models" + + def __str__(self): + return self.name + + @property + def computed_field(self): + """Document properties with docstrings.""" + return self.calculate_something() +``` + +**Model field conventions:** +- Use `null=True, blank=True` for optional fields +- Use `help_text` for user-facing field descriptions +- Use `limit_choices_to` for filtered ForeignKey choices +- Use `related_name` for reverse relations +- Set `on_delete=models.CASCADE` explicitly +- Use `default=None` with `null=True` for nullable fields + +### Admin Customization + +```python +@admin.register(MyModel) +class MyModelAdmin(admin.ModelAdmin): + list_display = ("name", "created", "custom_method") + list_filter = ("category", "created") + search_fields = ("name", "slug") + autocomplete_fields = ("foreign_key",) + readonly_fields = ("created", "updated") + save_as = True # Enable "Save as new" button + + @admin.display(description="Custom Display") + def custom_method(self, obj): + return format_html('{}', obj.name) +``` + +### Error Handling +```python +# Use Django's exception classes +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.http import Http404 +from django.db.utils import OperationalError, ProgrammingError + +# Handle database errors gracefully +try: + config = get_site_conf() +except (OperationalError, ProgrammingError): + config = default_config # Provide fallback +``` + +### Type Hints +- **Not currently used** in this project +- Follow existing patterns without type hints unless explicitly adding them + +## Django-Specific Patterns + +### Using BaseModel +All major models inherit from `ram.models.BaseModel`: +```python +from ram.models import BaseModel + +class MyModel(BaseModel): + # Automatically includes: uuid, description, notes, creation_time, + # updated_time, published, obj_type, obj_label properties + pass +``` + +### Using PublicManager +Models use `PublicManager` for filtering published items: +```python +from ram.managers import PublicManager + +objects = PublicManager() # Only returns items where published=True +``` + +### Image and Document Patterns +```python +from ram.models import Image, Document, PrivateDocument + +class MyImage(Image): + my_model = models.ForeignKey(MyModel, on_delete=models.CASCADE) + # Inherits: order, image, image_thumbnail() + +class MyDocument(PrivateDocument): + my_model = models.ForeignKey(MyModel, on_delete=models.CASCADE) + # Inherits: description, file, private, creation_time, updated_time +``` + +### Using DeduplicatedStorage +For media files that should be deduplicated: +```python +from ram.utils import DeduplicatedStorage + +image = models.ImageField(upload_to="images/", storage=DeduplicatedStorage) +``` + +## Testing Practices + +### Test Coverage +The project has comprehensive test coverage: +- **roster/tests.py**: RollingStock, RollingClass models (~340 lines, 19+ tests) +- **metadata/tests.py**: Scale, Manufacturer, Company, etc. (~378 lines, 29+ tests) +- **bookshelf/tests.py**: Book, Magazine, Catalog, TocEntry (~436 lines, 25+ tests) +- **consist/tests.py**: Consist, ConsistItem (~315 lines, 15+ tests) +- **ram/tests.py**: BaseModel, utility functions (~140 lines, 11+ tests) + +### Writing Tests +```python +from django.test import TestCase +from django.core.exceptions import ValidationError +from roster.models import RollingStock + +class RollingStockTestCase(TestCase): + def setUp(self): + """Set up test data.""" + # Create necessary related objects + self.company = Company.objects.create(name="RGS", country="US") + self.scale = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5) + # ... + + def test_road_number_int_extraction(self): + """Test automatic extraction of integer from road number.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="RGS-42", + scale=self.scale, + ) + self.assertEqual(stock.road_number_int, 42) + + def test_validation_error(self): + """Test that validation errors are raised correctly.""" + with self.assertRaises(ValidationError): + # Test validation logic + pass +``` + +**Testing best practices:** +- Use descriptive test method names with `test_` prefix +- Include docstrings explaining what each test verifies +- Create necessary test data in `setUp()` method +- Test both success and failure cases +- Use `assertRaises()` for exception testing +- Test model properties, methods, and validation logic + +## Git & Version Control + +- Branch: `master` (main development branch) +- CI runs on push and PR to master +- Follow conventional commit messages +- No pre-commit hooks configured (consider adding) + +## Additional Notes + +- **Settings override**: Use `ram/local_settings.py` for local configuration +- **Debug mode**: `DEBUG = True` in settings.py (change for production) +- **Database**: SQLite by default (in `storage/db.sqlite3`) +- **Static files**: Bootstrap 5.3.8, Bootstrap Icons 1.13.1 +- **Rich text**: TinyMCE for HTMLField content +- **REST API**: Disabled by default (`REST_ENABLED = False`) +- **Security**: CSP middleware enabled, secure cookies in production diff --git a/ram/bookshelf/tests.py b/ram/bookshelf/tests.py index 7ce503c..3729f76 100644 --- a/ram/bookshelf/tests.py +++ b/ram/bookshelf/tests.py @@ -1,3 +1,436 @@ +from decimal import Decimal from django.test import TestCase +from django.core.exceptions import ValidationError +from django.db import IntegrityError -# Create your tests here. +from bookshelf.models import ( + Author, + Publisher, + Book, + Catalog, + Magazine, + MagazineIssue, + TocEntry, +) +from metadata.models import Manufacturer, Scale + + +class AuthorTestCase(TestCase): + """Test cases for Author model.""" + + def test_author_creation(self): + """Test creating an author.""" + author = Author.objects.create( + first_name="John", + last_name="Smith", + ) + + self.assertEqual(str(author), "Smith, John") + self.assertEqual(author.first_name, "John") + self.assertEqual(author.last_name, "Smith") + + def test_author_short_name(self): + """Test author short name property.""" + author = Author.objects.create( + first_name="John", + last_name="Smith", + ) + + self.assertEqual(author.short_name, "Smith J.") + + def test_author_ordering(self): + """Test author ordering by last name, first name.""" + a1 = Author.objects.create(first_name="John", last_name="Smith") + a2 = Author.objects.create(first_name="Jane", last_name="Doe") + a3 = Author.objects.create(first_name="Bob", last_name="Smith") + + authors = list(Author.objects.all()) + self.assertEqual(authors[0], a2) # Doe comes first + self.assertEqual(authors[1], a3) # Smith, Bob + self.assertEqual(authors[2], a1) # Smith, John + + +class PublisherTestCase(TestCase): + """Test cases for Publisher model.""" + + def test_publisher_creation(self): + """Test creating a publisher.""" + publisher = Publisher.objects.create( + name="Model Railroader", + country="US", + website="https://www.modelrailroader.com", + ) + + self.assertEqual(str(publisher), "Model Railroader") + self.assertEqual(publisher.country.code, "US") + + def test_publisher_ordering(self): + """Test publisher ordering by name.""" + p1 = Publisher.objects.create(name="Zebra Publishing") + p2 = Publisher.objects.create(name="Alpha Books") + p3 = Publisher.objects.create(name="Model Railroader") + + publishers = list(Publisher.objects.all()) + self.assertEqual(publishers[0], p2) + self.assertEqual(publishers[1], p3) + self.assertEqual(publishers[2], p1) + + +class BookTestCase(TestCase): + """Test cases for Book model.""" + + def setUp(self): + """Set up test data.""" + self.publisher = Publisher.objects.create( + name="Kalmbach Publishing", + country="US", + ) + + self.author = Author.objects.create( + first_name="Tony", + last_name="Koester", + ) + + def test_book_creation(self): + """Test creating a book.""" + book = Book.objects.create( + title="Model Railroad Planning", + publisher=self.publisher, + ISBN="978-0-89024-567-8", + language="en", + number_of_pages=128, + publication_year=2010, + price=Decimal("24.95"), + ) + + self.assertEqual(str(book), "Model Railroad Planning") + self.assertEqual(book.publisher_name, "Kalmbach Publishing") + self.assertTrue(book.published) # Default from BaseModel + + def test_book_authors_relationship(self): + """Test many-to-many relationship with authors.""" + book = Book.objects.create( + title="Test Book", + publisher=self.publisher, + ) + + author2 = Author.objects.create( + first_name="John", + last_name="Doe", + ) + + book.authors.add(self.author, author2) + + self.assertEqual(book.authors.count(), 2) + self.assertIn(self.author, book.authors.all()) + + def test_book_authors_list_property(self): + """Test authors_list property.""" + book = Book.objects.create( + title="Test Book", + publisher=self.publisher, + ) + + book.authors.add(self.author) + + self.assertEqual(book.authors_list, "Koester T.") + + def test_book_ordering(self): + """Test book ordering by title.""" + b1 = Book.objects.create( + title="Zebra Book", + publisher=self.publisher, + ) + b2 = Book.objects.create( + title="Alpha Book", + publisher=self.publisher, + ) + + books = list(Book.objects.all()) + self.assertEqual(books[0], b2) + self.assertEqual(books[1], b1) + + +class CatalogTestCase(TestCase): + """Test cases for Catalog model.""" + + def setUp(self): + """Set up test data.""" + self.manufacturer = Manufacturer.objects.create( + name="Bachmann", + category="model", + country="US", + ) + + self.scale_ho = Scale.objects.create( + scale="HO", + ratio="1:87", + tracks=16.5, + ) + + self.scale_n = Scale.objects.create( + scale="N", + ratio="1:160", + tracks=9.0, + ) + + def test_catalog_creation(self): + """Test creating a catalog.""" + catalog = Catalog.objects.create( + manufacturer=self.manufacturer, + years="2023", + publication_year=2023, + ) + catalog.scales.add(self.scale_ho) + + # Refresh to get the correct string representation + catalog.refresh_from_db() + + self.assertIn("Bachmann", str(catalog)) + self.assertIn("2023", str(catalog)) + + def test_catalog_multiple_scales(self): + """Test catalog with multiple scales.""" + catalog = Catalog.objects.create( + manufacturer=self.manufacturer, + years="2023", + ) + + catalog.scales.add(self.scale_ho, self.scale_n) + + scales_str = catalog.get_scales() + self.assertIn("HO", scales_str) + self.assertIn("N", scales_str) + + def test_catalog_ordering(self): + """Test catalog ordering by manufacturer and year.""" + man2 = Manufacturer.objects.create( + name="Atlas", + category="model", + ) + + c1 = Catalog.objects.create( + manufacturer=self.manufacturer, + years="2023", + publication_year=2023, + ) + c2 = Catalog.objects.create( + manufacturer=man2, + years="2023", + publication_year=2023, + ) + c3 = Catalog.objects.create( + manufacturer=self.manufacturer, + years="2022", + publication_year=2022, + ) + + catalogs = list(Catalog.objects.all()) + # Should be ordered by manufacturer name, then year + self.assertEqual(catalogs[0], c2) # Atlas + + +class MagazineTestCase(TestCase): + """Test cases for Magazine model.""" + + def setUp(self): + """Set up test data.""" + self.publisher = Publisher.objects.create( + name="Kalmbach Publishing", + country="US", + ) + + def test_magazine_creation(self): + """Test creating a magazine.""" + magazine = Magazine.objects.create( + name="Model Railroader", + publisher=self.publisher, + website="https://www.modelrailroader.com", + ISBN="0746-9896", + language="en", + ) + + self.assertEqual(str(magazine), "Model Railroader") + self.assertEqual(magazine.publisher, self.publisher) + + def test_magazine_website_short(self): + """Test website_short method.""" + magazine = Magazine.objects.create( + name="Model Railroader", + publisher=self.publisher, + website="https://www.modelrailroader.com", + ) + + self.assertEqual(magazine.website_short(), "modelrailroader.com") + + def test_magazine_get_cover_no_image(self): + """Test get_cover when magazine has no image.""" + magazine = Magazine.objects.create( + name="Test Magazine", + publisher=self.publisher, + ) + + # Should return None if no cover image exists + self.assertIsNone(magazine.get_cover()) + + +class MagazineIssueTestCase(TestCase): + """Test cases for MagazineIssue model.""" + + def setUp(self): + """Set up test data.""" + self.publisher = Publisher.objects.create( + name="Kalmbach Publishing", + ) + + self.magazine = Magazine.objects.create( + name="Model Railroader", + publisher=self.publisher, + published=True, + ) + + def test_magazine_issue_creation(self): + """Test creating a magazine issue.""" + issue = MagazineIssue.objects.create( + magazine=self.magazine, + issue_number="January 2023", + publication_year=2023, + publication_month=1, + number_of_pages=96, + ) + + self.assertEqual(str(issue), "Model Railroader - January 2023") + self.assertEqual(issue.obj_label, "Magazine Issue") + + def test_magazine_issue_unique_together(self): + """Test that magazine+issue_number must be unique.""" + MagazineIssue.objects.create( + magazine=self.magazine, + issue_number="January 2023", + ) + + with self.assertRaises(IntegrityError): + MagazineIssue.objects.create( + magazine=self.magazine, + issue_number="January 2023", + ) + + def test_magazine_issue_validation(self): + """Test that published issue requires published magazine.""" + unpublished_magazine = Magazine.objects.create( + name="Unpublished Magazine", + publisher=self.publisher, + published=False, + ) + + issue = MagazineIssue( + magazine=unpublished_magazine, + issue_number="Test Issue", + published=True, + ) + + with self.assertRaises(ValidationError): + issue.clean() + + def test_magazine_issue_publisher_property(self): + """Test that issue inherits publisher from magazine.""" + issue = MagazineIssue.objects.create( + magazine=self.magazine, + issue_number="January 2023", + ) + + self.assertEqual(issue.publisher, self.publisher) + + +class TocEntryTestCase(TestCase): + """Test cases for TocEntry model.""" + + def setUp(self): + """Set up test data.""" + publisher = Publisher.objects.create(name="Test Publisher") + + self.book = Book.objects.create( + title="Test Book", + publisher=publisher, + number_of_pages=200, + ) + + def test_toc_entry_creation(self): + """Test creating a table of contents entry.""" + entry = TocEntry.objects.create( + book=self.book, + title="Introduction to Model Railroading", + subtitle="Getting Started", + authors="John Doe", + page=10, + ) + + self.assertIn("Introduction to Model Railroading", str(entry)) + self.assertIn("Getting Started", str(entry)) + self.assertIn("p. 10", str(entry)) + + def test_toc_entry_without_subtitle(self): + """Test TOC entry without subtitle.""" + entry = TocEntry.objects.create( + book=self.book, + title="Chapter One", + page=5, + ) + + self.assertEqual(str(entry), "Chapter One (p. 5)") + + def test_toc_entry_page_validation_required(self): + """Test that page number is required.""" + entry = TocEntry( + book=self.book, + title="Test Entry", + page=None, + ) + + with self.assertRaises(ValidationError): + entry.clean() + + def test_toc_entry_page_validation_min(self): + """Test that page number must be >= 1.""" + entry = TocEntry( + book=self.book, + title="Test Entry", + page=0, + ) + + with self.assertRaises(ValidationError): + entry.clean() + + def test_toc_entry_page_validation_exceeds_book(self): + """Test that page number cannot exceed book's page count.""" + entry = TocEntry( + book=self.book, + title="Test Entry", + page=250, # Book has 200 pages + ) + + with self.assertRaises(ValidationError): + entry.clean() + + def test_toc_entry_ordering(self): + """Test TOC entries are ordered by page number.""" + e1 = TocEntry.objects.create( + book=self.book, + title="Chapter Three", + page=30, + ) + e2 = TocEntry.objects.create( + book=self.book, + title="Chapter One", + page=10, + ) + e3 = TocEntry.objects.create( + book=self.book, + title="Chapter Two", + page=20, + ) + + entries = list(TocEntry.objects.all()) + self.assertEqual(entries[0], e2) # Page 10 + self.assertEqual(entries[1], e3) # Page 20 + self.assertEqual(entries[2], e1) # Page 30 diff --git a/ram/consist/tests.py b/ram/consist/tests.py index 7ce503c..3a4c535 100644 --- a/ram/consist/tests.py +++ b/ram/consist/tests.py @@ -1,3 +1,315 @@ from django.test import TestCase +from django.core.exceptions import ValidationError +from django.db import IntegrityError -# Create your tests here. +from consist.models import Consist, ConsistItem +from roster.models import RollingClass, RollingStock +from metadata.models import Company, Scale, RollingStockType + + +class ConsistTestCase(TestCase): + """Test cases for Consist model.""" + + def setUp(self): + """Set up test data.""" + self.company = Company.objects.create( + name="Rio Grande Southern", + country="US", + ) + + self.scale = Scale.objects.create( + scale="HOn3", + ratio="1:87", + tracks=10.5, + ) + + def test_consist_creation(self): + """Test creating a consist.""" + consist = Consist.objects.create( + identifier="RGS Freight #1", + company=self.company, + scale=self.scale, + era="1930s", + ) + + self.assertEqual(str(consist), "Rio Grande Southern RGS Freight #1") + self.assertEqual(consist.identifier, "RGS Freight #1") + self.assertEqual(consist.era, "1930s") + + def test_consist_country_property(self): + """Test that consist inherits country from company.""" + consist = Consist.objects.create( + identifier="Test Consist", + company=self.company, + scale=self.scale, + ) + + self.assertEqual(consist.country, self.company.country) + + def test_consist_dcc_address(self): + """Test consist with DCC address.""" + consist = Consist.objects.create( + identifier="DCC Consist", + company=self.company, + scale=self.scale, + consist_address=99, + ) + + self.assertEqual(consist.consist_address, 99) + + def test_consist_get_absolute_url(self): + """Test get_absolute_url returns correct URL.""" + consist = Consist.objects.create( + identifier="Test Consist", + company=self.company, + scale=self.scale, + ) + + url = consist.get_absolute_url() + self.assertIn(str(consist.uuid), url) + + +class ConsistItemTestCase(TestCase): + """Test cases for ConsistItem model.""" + + def setUp(self): + """Set up test data.""" + self.company = Company.objects.create(name="RGS", country="US") + + self.scale_hon3 = Scale.objects.create( + scale="HOn3", + ratio="1:87", + tracks=10.5, + ) + + self.scale_ho = Scale.objects.create( + scale="HO", + ratio="1:87", + tracks=16.5, + ) + + self.stock_type = RollingStockType.objects.create( + type="Steam Locomotive", + category="locomotive", + order=1, + ) + + self.rolling_class = RollingClass.objects.create( + identifier="C-19", + type=self.stock_type, + company=self.company, + ) + + self.consist = Consist.objects.create( + identifier="Test Consist", + company=self.company, + scale=self.scale_hon3, + published=True, + ) + + self.rolling_stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale_hon3, + published=True, + ) + + def test_consist_item_creation(self): + """Test creating a consist item.""" + item = ConsistItem.objects.create( + consist=self.consist, + rolling_stock=self.rolling_stock, + order=1, + load=False, + ) + + self.assertEqual(str(item), "RGS C-19 340") + self.assertEqual(item.order, 1) + self.assertFalse(item.load) + + def test_consist_item_unique_constraint(self): + """Test that consist+rolling_stock must be unique.""" + ConsistItem.objects.create( + consist=self.consist, + rolling_stock=self.rolling_stock, + order=1, + ) + + # Cannot add same rolling stock to same consist twice + with self.assertRaises(IntegrityError): + ConsistItem.objects.create( + consist=self.consist, + rolling_stock=self.rolling_stock, + order=2, + ) + + def test_consist_item_scale_validation(self): + """Test that consist item scale must match consist scale.""" + different_scale_stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="341", + scale=self.scale_ho, # Different scale + ) + + item = ConsistItem( + consist=self.consist, + rolling_stock=different_scale_stock, + order=1, + load=False, + ) + + with self.assertRaises(ValidationError): + item.clean() + + def test_consist_item_load_ratio_validation(self): + """Test that load ratio must match consist ratio.""" + different_scale = Scale.objects.create( + scale="N", + ratio="1:160", # Different ratio + tracks=9.0, + ) + + load_stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="342", + scale=different_scale, + ) + + item = ConsistItem( + consist=self.consist, + rolling_stock=load_stock, + order=1, + load=True, + ) + + with self.assertRaises(ValidationError): + item.clean() + + def test_consist_item_published_validation(self): + """Test that unpublished stock cannot be in published consist.""" + unpublished_stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="343", + scale=self.scale_hon3, + published=False, + ) + + item = ConsistItem( + consist=self.consist, + rolling_stock=unpublished_stock, + order=1, + ) + + with self.assertRaises(ValidationError): + item.clean() + + def test_consist_item_properties(self): + """Test consist item properties.""" + item = ConsistItem.objects.create( + consist=self.consist, + rolling_stock=self.rolling_stock, + order=1, + ) + + self.assertEqual(item.scale, self.rolling_stock.scale) + self.assertEqual(item.company, self.rolling_stock.company) + self.assertEqual(item.type, self.stock_type.type) + + def test_consist_length_calculation(self): + """Test consist length calculation.""" + # Add three items (not loads) + for i in range(3): + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number=str(340 + i), + scale=self.scale_hon3, + ) + ConsistItem.objects.create( + consist=self.consist, + rolling_stock=stock, + order=i + 1, + load=False, + ) + + self.assertEqual(self.consist.length, 3) + + def test_consist_length_excludes_loads(self): + """Test that consist length excludes loads.""" + # Add one regular item + ConsistItem.objects.create( + consist=self.consist, + rolling_stock=self.rolling_stock, + order=1, + load=False, + ) + + # Add one load (same ratio, different scale tracks OK for loads) + load_stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="LOAD-1", + scale=self.scale_hon3, + ) + ConsistItem.objects.create( + consist=self.consist, + rolling_stock=load_stock, + order=2, + load=True, + ) + + # Length should only count non-load items + self.assertEqual(self.consist.length, 1) + + def test_consist_item_ordering(self): + """Test consist items are ordered by order field.""" + stock2 = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="341", + scale=self.scale_hon3, + ) + stock3 = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="342", + scale=self.scale_hon3, + ) + + item3 = ConsistItem.objects.create( + consist=self.consist, + rolling_stock=stock3, + order=3, + ) + item1 = ConsistItem.objects.create( + consist=self.consist, + rolling_stock=self.rolling_stock, + order=1, + ) + item2 = ConsistItem.objects.create( + consist=self.consist, + rolling_stock=stock2, + order=2, + ) + + items = list(self.consist.consist_item.all()) + self.assertEqual(items[0], item1) + self.assertEqual(items[1], item2) + self.assertEqual(items[2], item3) + + def test_unpublish_consist_signal(self): + """Test that unpublishing rolling stock unpublishes consists.""" + # Create a consist item + ConsistItem.objects.create( + consist=self.consist, + rolling_stock=self.rolling_stock, + order=1, + ) + + self.assertTrue(self.consist.published) + + # Unpublish the rolling stock + self.rolling_stock.published = False + self.rolling_stock.save() + + # Reload consist from database + self.consist.refresh_from_db() + + # Consist should now be unpublished + self.assertFalse(self.consist.published) diff --git a/ram/metadata/tests.py b/ram/metadata/tests.py index 7ce503c..041ef1e 100644 --- a/ram/metadata/tests.py +++ b/ram/metadata/tests.py @@ -1,3 +1,371 @@ from django.test import TestCase +from django.core.exceptions import ValidationError +from django.db import IntegrityError -# Create your tests here. +from metadata.models import ( + Manufacturer, + Company, + Scale, + RollingStockType, + Decoder, + Shop, + Tag, + calculate_ratio, +) + + +class ManufacturerTestCase(TestCase): + """Test cases for Manufacturer model.""" + + def test_manufacturer_creation(self): + """Test creating a manufacturer.""" + manufacturer = Manufacturer.objects.create( + name="Blackstone Models", + category="model", + country="US", + website="https://www.blackstonemodels.com", + ) + + self.assertEqual(str(manufacturer), "Blackstone Models") + self.assertEqual(manufacturer.slug, "blackstone-models") + self.assertEqual(manufacturer.category, "model") + + def test_manufacturer_slug_auto_generation(self): + """Test that slug is automatically generated.""" + manufacturer = Manufacturer.objects.create( + name="Baldwin Locomotive Works", + category="real", + ) + + self.assertEqual(manufacturer.slug, "baldwin-locomotive-works") + + def test_manufacturer_unique_constraint(self): + """Test that name+category must be unique.""" + Manufacturer.objects.create( + name="Baldwin", + category="real", + ) + + # Should not be able to create another with same name+category + with self.assertRaises(IntegrityError): + Manufacturer.objects.create( + name="Baldwin", + category="real", + ) + + def test_manufacturer_different_categories(self): + """Test that same name is allowed with different categories.""" + Manufacturer.objects.create( + name="Baldwin", + category="real", + ) + + # Should be able to create with different category + manufacturer2 = Manufacturer.objects.create( + name="Alco", + category="model", + ) + + self.assertEqual(manufacturer2.name, "Alco") + self.assertIsNotNone(manufacturer2.pk) + + def test_manufacturer_website_short(self): + """Test website_short extracts domain.""" + manufacturer = Manufacturer.objects.create( + name="Test Manufacturer", + category="model", + website="https://www.example.com/path", + ) + + self.assertEqual(manufacturer.website_short(), "example.com") + + def test_manufacturer_ordering(self): + """Test manufacturer ordering by category and slug.""" + m1 = Manufacturer.objects.create(name="Zebra", category="model") + m2 = Manufacturer.objects.create(name="Alpha", category="accessory") + m3 = Manufacturer.objects.create(name="Beta", category="model") + + manufacturers = list(Manufacturer.objects.all()) + # Ordered by category, then slug + self.assertEqual(manufacturers[0], m2) # accessory comes first + self.assertTrue(manufacturers.index(m3) < manufacturers.index(m1)) + + +class CompanyTestCase(TestCase): + """Test cases for Company model.""" + + def test_company_creation(self): + """Test creating a company.""" + company = Company.objects.create( + name="RGS", + extended_name="Rio Grande Southern Railroad", + country="US", + freelance=False, + ) + + self.assertEqual(str(company), "RGS") + self.assertEqual(company.slug, "rgs") + self.assertEqual(company.extended_name, "Rio Grande Southern Railroad") + + def test_company_slug_generation(self): + """Test automatic slug generation.""" + company = Company.objects.create( + name="Denver & Rio Grande Western", + country="US", + ) + + self.assertEqual(company.slug, "denver-rio-grande-western") + + def test_company_unique_name(self): + """Test that company name must be unique.""" + Company.objects.create(name="RGS", country="US") + + with self.assertRaises(IntegrityError): + Company.objects.create(name="RGS", country="GB") + + def test_company_extended_name_pp(self): + """Test extended name pretty print.""" + company = Company.objects.create( + name="RGS", + extended_name="Rio Grande Southern Railroad", + country="US", + ) + + self.assertEqual( + company.extended_name_pp(), + "(Rio Grande Southern Railroad)" + ) + + def test_company_extended_name_pp_empty(self): + """Test extended name pretty print when empty.""" + company = Company.objects.create(name="RGS", country="US") + + self.assertEqual(company.extended_name_pp(), "") + + def test_company_freelance_flag(self): + """Test freelance flag.""" + company = Company.objects.create( + name="Fake Railroad", + country="US", + freelance=True, + ) + + self.assertTrue(company.freelance) + + +class ScaleTestCase(TestCase): + """Test cases for Scale model.""" + + def test_scale_creation(self): + """Test creating a scale.""" + scale = Scale.objects.create( + scale="HOn3", + ratio="1:87", + tracks=10.5, + gauge="3 ft", + ) + + self.assertEqual(str(scale), "HOn3") + self.assertEqual(scale.slug, "hon3") + self.assertEqual(scale.ratio, "1:87") + self.assertEqual(scale.tracks, 10.5) + + def test_scale_ratio_calculation(self): + """Test automatic ratio_int calculation.""" + scale = Scale.objects.create( + scale="HO", + ratio="1:87", + tracks=16.5, + ) + + # 1/87 * 10000 = 114.94... + self.assertAlmostEqual(scale.ratio_int, 114, delta=1) + + def test_scale_ratio_validation_valid(self): + """Test that valid ratios are accepted.""" + ratios = ["1:87", "1:160", "1:22.5", "1:48"] + + for ratio in ratios: + result = calculate_ratio(ratio) + self.assertIsInstance(result, (int, float)) + + def test_scale_ratio_validation_invalid(self): + """Test that invalid ratios raise ValidationError.""" + with self.assertRaises(ValidationError): + calculate_ratio("invalid") + + with self.assertRaises(ValidationError): + calculate_ratio("1:0") # Division by zero + + def test_scale_ordering(self): + """Test scale ordering by ratio_int (descending).""" + s1 = Scale.objects.create(scale="G", ratio="1:22.5", tracks=45.0) + s2 = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5) + s3 = Scale.objects.create(scale="N", ratio="1:160", tracks=9.0) + + scales = list(Scale.objects.all()) + # Ordered by -ratio_int (larger ratios first) + self.assertEqual(scales[0], s1) # G scale (largest) + self.assertEqual(scales[1], s2) # HO scale + self.assertEqual(scales[2], s3) # N scale (smallest) + + +class RollingStockTypeTestCase(TestCase): + """Test cases for RollingStockType model.""" + + def test_rolling_stock_type_creation(self): + """Test creating a rolling stock type.""" + stock_type = RollingStockType.objects.create( + type="Steam Locomotive", + category="locomotive", + order=1, + ) + + self.assertEqual(str(stock_type), "Steam Locomotive locomotive") + self.assertEqual(stock_type.slug, "steam-locomotive-locomotive") + + def test_rolling_stock_type_unique_constraint(self): + """Test that category+type must be unique.""" + RollingStockType.objects.create( + type="Steam Locomotive", + category="locomotive", + order=1, + ) + + with self.assertRaises(IntegrityError): + RollingStockType.objects.create( + type="Steam Locomotive", + category="locomotive", + order=2, + ) + + def test_rolling_stock_type_ordering(self): + """Test ordering by order field.""" + t3 = RollingStockType.objects.create( + type="Caboose", category="railcar", order=3 + ) + t1 = RollingStockType.objects.create( + type="Steam", category="locomotive", order=1 + ) + t2 = RollingStockType.objects.create( + type="Boxcar", category="railcar", order=2 + ) + + types = list(RollingStockType.objects.all()) + self.assertEqual(types[0], t1) + self.assertEqual(types[1], t2) + self.assertEqual(types[2], t3) + + +class DecoderTestCase(TestCase): + """Test cases for Decoder model.""" + + def setUp(self): + """Set up test data.""" + self.manufacturer = Manufacturer.objects.create( + name="ESU", + category="accessory", + country="DE", + ) + + def test_decoder_creation(self): + """Test creating a decoder.""" + decoder = Decoder.objects.create( + name="LokSound 5", + manufacturer=self.manufacturer, + version="5.0", + sound=True, + ) + + self.assertEqual(str(decoder), "ESU - LokSound 5") + self.assertTrue(decoder.sound) + + def test_decoder_without_sound(self): + """Test creating a non-sound decoder.""" + decoder = Decoder.objects.create( + name="LokPilot 5", + manufacturer=self.manufacturer, + sound=False, + ) + + self.assertFalse(decoder.sound) + + def test_decoder_ordering(self): + """Test decoder ordering by manufacturer name and decoder name.""" + man2 = Manufacturer.objects.create( + name="Digitrax", + category="accessory", + ) + + d1 = Decoder.objects.create( + name="LokSound 5", + manufacturer=self.manufacturer, + ) + d2 = Decoder.objects.create( + name="DZ123", + manufacturer=man2, + ) + d3 = Decoder.objects.create( + name="LokPilot 5", + manufacturer=self.manufacturer, + ) + + decoders = list(Decoder.objects.all()) + # Ordered by manufacturer name, then decoder name + self.assertEqual(decoders[0], d2) # Digitrax + self.assertTrue(decoders.index(d3) < decoders.index(d1)) # LokPilot before LokSound + + +class ShopTestCase(TestCase): + """Test cases for Shop model.""" + + def test_shop_creation(self): + """Test creating a shop.""" + shop = Shop.objects.create( + name="Caboose Hobbies", + country="US", + website="https://www.caboosehobbies.com", + on_line=True, + active=True, + ) + + self.assertEqual(str(shop), "Caboose Hobbies") + self.assertTrue(shop.on_line) + self.assertTrue(shop.active) + + def test_shop_defaults(self): + """Test shop default values.""" + shop = Shop.objects.create(name="Local Shop") + + self.assertTrue(shop.on_line) # Default True + self.assertTrue(shop.active) # Default True + + def test_shop_offline(self): + """Test creating an offline shop.""" + shop = Shop.objects.create( + name="Brick and Mortar Store", + on_line=False, + ) + + self.assertFalse(shop.on_line) + + +class TagTestCase(TestCase): + """Test cases for Tag model.""" + + def test_tag_creation(self): + """Test creating a tag.""" + tag = Tag.objects.create( + name="Narrow Gauge", + slug="narrow-gauge", + ) + + self.assertEqual(str(tag), "Narrow Gauge") + self.assertEqual(tag.slug, "narrow-gauge") + + def test_tag_unique_name(self): + """Test that tag name must be unique.""" + Tag.objects.create(name="Narrow Gauge", slug="narrow-gauge") + + with self.assertRaises(IntegrityError): + Tag.objects.create(name="Narrow Gauge", slug="narrow-gauge") diff --git a/ram/ram/tests.py b/ram/ram/tests.py new file mode 100644 index 0000000..a26160e --- /dev/null +++ b/ram/ram/tests.py @@ -0,0 +1,139 @@ +from django.test import TestCase + +from ram.utils import slugify + + +class SimpleBaseModelTestCase(TestCase): + """Test cases for SimpleBaseModel.""" + + def test_obj_type_property(self): + """Test obj_type returns model name.""" + # We can't instantiate abstract models directly, + # so we test via a concrete model that inherits from it + from metadata.models import Company + + company = Company.objects.create(name="Test", country="US") + self.assertEqual(company.obj_type, "company") + + def test_obj_label_property(self): + """Test obj_label returns object name.""" + from metadata.models import Company + + company = Company.objects.create(name="Test", country="US") + self.assertEqual(company.obj_label, "Company") + + +class BaseModelTestCase(TestCase): + """Test cases for BaseModel.""" + + def test_base_model_fields(self): + """Test that BaseModel includes expected fields.""" + # Test via a concrete model + from roster.models import RollingStock, RollingClass + from metadata.models import Company, Scale, RollingStockType + + company = Company.objects.create(name="Test", country="US") + scale = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5) + stock_type = RollingStockType.objects.create( + type="Test", category="locomotive", order=1 + ) + rolling_class = RollingClass.objects.create( + identifier="Test", + type=stock_type, + company=company, + ) + + stock = RollingStock.objects.create( + rolling_class=rolling_class, + road_number="123", + scale=scale, + description="

Test description

", + notes="Test notes", + ) + + # Check BaseModel fields exist + self.assertIsNotNone(stock.uuid) + self.assertTrue(stock.published) + self.assertIsNotNone(stock.creation_time) + self.assertIsNotNone(stock.updated_time) + self.assertEqual(stock.description, "

Test description

") + + def test_base_model_obj_properties(self): + """Test obj_type and obj_label properties.""" + from roster.models import RollingStock, RollingClass + from metadata.models import Company, Scale, RollingStockType + + company = Company.objects.create(name="Test", country="US") + scale = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5) + stock_type = RollingStockType.objects.create( + type="Test", category="locomotive", order=1 + ) + rolling_class = RollingClass.objects.create( + identifier="Test", + type=stock_type, + company=company, + ) + + stock = RollingStock.objects.create( + rolling_class=rolling_class, + road_number="123", + scale=scale, + ) + + self.assertEqual(stock.obj_type, "rollingstock") + self.assertEqual(stock.obj_label, "RollingStock") + + def test_base_model_published_default(self): + """Test that published defaults to True.""" + from roster.models import RollingStock, RollingClass + from metadata.models import Company, Scale, RollingStockType + + company = Company.objects.create(name="Test", country="US") + scale = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5) + stock_type = RollingStockType.objects.create( + type="Test", category="locomotive", order=1 + ) + rolling_class = RollingClass.objects.create( + identifier="Test", + type=stock_type, + company=company, + ) + + stock = RollingStock.objects.create( + rolling_class=rolling_class, + road_number="123", + scale=scale, + ) + + self.assertTrue(stock.published) + + +class SlugifyTestCase(TestCase): + """Test cases for slugify utility function.""" + + def test_slugify_basic(self): + """Test basic slugification.""" + self.assertEqual(slugify("Hello World"), "hello-world") + + def test_slugify_special_characters(self): + """Test slugification with special characters.""" + self.assertEqual(slugify("Hello & World!"), "hello-world") + + def test_slugify_multiple_spaces(self): + """Test slugification with multiple spaces.""" + self.assertEqual(slugify("Hello World"), "hello-world") + + def test_slugify_numbers(self): + """Test slugification with numbers.""" + self.assertEqual(slugify("Test 123 ABC"), "test-123-abc") + + def test_slugify_underscores(self): + """Test slugification preserves underscores.""" + result = slugify("test_value") + # Depending on implementation, may keep or convert underscores + self.assertIn(result, ["test-value", "test_value"]) + + def test_slugify_empty_string(self): + """Test slugification of empty string.""" + result = slugify("") + self.assertEqual(result, "") diff --git a/ram/roster/tests.py b/ram/roster/tests.py index 7ce503c..890c75f 100644 --- a/ram/roster/tests.py +++ b/ram/roster/tests.py @@ -1,3 +1,340 @@ +from decimal import Decimal from django.test import TestCase +from django.core.exceptions import ValidationError +from django.conf import settings -# Create your tests here. +from roster.models import RollingClass, RollingStock, RollingStockImage +from metadata.models import ( + Company, + Manufacturer, + Scale, + RollingStockType, + Decoder, +) + + +class RollingClassTestCase(TestCase): + """Test cases for RollingClass model.""" + + def setUp(self): + """Set up test data.""" + # Create a company + self.company = Company.objects.create( + name="Rio Grande Southern", + country="US", + ) + + # Create a rolling stock type + self.stock_type = RollingStockType.objects.create( + type="Steam Locomotive", + category="locomotive", + order=1, + ) + + # Create a real manufacturer + self.real_manufacturer = Manufacturer.objects.create( + name="Baldwin Locomotive Works", + category="real", + country="US", + ) + + def test_rolling_class_creation(self): + """Test creating a rolling class.""" + rolling_class = RollingClass.objects.create( + identifier="C-19", + type=self.stock_type, + company=self.company, + description="

Narrow gauge steam locomotive

", + ) + + self.assertEqual(str(rolling_class), "Rio Grande Southern C-19") + self.assertEqual(rolling_class.identifier, "C-19") + self.assertEqual(rolling_class.type, self.stock_type) + self.assertEqual(rolling_class.company, self.company) + + def test_rolling_class_country_property(self): + """Test that rolling class inherits country from company.""" + rolling_class = RollingClass.objects.create( + identifier="C-19", + type=self.stock_type, + company=self.company, + ) + + self.assertEqual(rolling_class.country, self.company.country) + + def test_rolling_class_ordering(self): + """Test rolling class ordering by company and identifier.""" + company2 = Company.objects.create(name="D&RGW", country="US") + + rc1 = RollingClass.objects.create( + identifier="K-27", type=self.stock_type, company=company2 + ) + rc2 = RollingClass.objects.create( + identifier="C-19", type=self.stock_type, company=self.company + ) + rc3 = RollingClass.objects.create( + identifier="K-28", type=self.stock_type, company=company2 + ) + + classes = list(RollingClass.objects.all()) + self.assertEqual(classes[0], rc1) # D&RGW K-27 + self.assertEqual(classes[1], rc3) # D&RGW K-28 + self.assertEqual(classes[2], rc2) # Rio Grande Southern comes last + + def test_rolling_class_manufacturer_relationship(self): + """Test many-to-many relationship with manufacturers.""" + rolling_class = RollingClass.objects.create( + identifier="C-19", + type=self.stock_type, + company=self.company, + ) + + rolling_class.manufacturer.add(self.real_manufacturer) + + self.assertEqual(rolling_class.manufacturer.count(), 1) + self.assertIn(self.real_manufacturer, rolling_class.manufacturer.all()) + + +class RollingStockTestCase(TestCase): + """Test cases for RollingStock model.""" + + def setUp(self): + """Set up test data.""" + # Create necessary related objects + self.company = Company.objects.create(name="RGS", country="US") + + self.stock_type = RollingStockType.objects.create( + type="Steam Locomotive", + category="locomotive", + order=1, + ) + + self.rolling_class = RollingClass.objects.create( + identifier="C-19", + type=self.stock_type, + company=self.company, + ) + + self.scale = Scale.objects.create( + scale="HOn3", + ratio="1:87", + tracks=10.5, + gauge="3 ft", + ) + + self.model_manufacturer = Manufacturer.objects.create( + name="Blackstone Models", + category="model", + country="US", + ) + + def test_rolling_stock_creation(self): + """Test creating rolling stock.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + manufacturer=self.model_manufacturer, + ) + + self.assertEqual(str(stock), "RGS C-19 340") + self.assertEqual(stock.road_number, "340") + self.assertEqual(stock.road_number_int, 340) + self.assertTrue(stock.published) + + def test_road_number_int_extraction(self): + """Test automatic extraction of integer from road number.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="RGS-42", + scale=self.scale, + ) + + self.assertEqual(stock.road_number_int, 42) + + def test_road_number_no_integer(self): + """Test road number with no integer defaults to 0.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="N/A", + scale=self.scale, + ) + + self.assertEqual(stock.road_number_int, 0) + + def test_item_number_slug_generation(self): + """Test automatic slug generation from item number.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + item_number="BLI-123 ABC", + ) + + self.assertEqual(stock.item_number_slug, "bli-123-abc") + + def test_featured_limit_validation(self): + """Test that featured items are limited by FEATURED_ITEMS_MAX.""" + # Create FEATURED_ITEMS_MAX featured items + for i in range(settings.FEATURED_ITEMS_MAX): + RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number=str(i), + scale=self.scale, + featured=True, + ) + + # Try to create one more featured item + extra_stock = RollingStock( + rolling_class=self.rolling_class, + road_number="999", + scale=self.scale, + featured=True, + ) + + with self.assertRaises(ValidationError) as cm: + extra_stock.clean() + + self.assertIn("featured items", str(cm.exception)) + + def test_price_decimal_field(self): + """Test price field accepts decimal values.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + price=Decimal("249.99"), + ) + + self.assertEqual(stock.price, Decimal("249.99")) + + def test_decoder_interface_display(self): + """Test decoder interface display method.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + decoder_interface=1, # 21-pin interface + ) + + interface = stock.get_decoder_interface() + self.assertIsNotNone(interface) + self.assertNotEqual(interface, "No interface") + + def test_decoder_interface_none(self): + """Test decoder interface when not set.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + ) + + interface = stock.get_decoder_interface() + self.assertEqual(interface, "No interface") + + def test_country_and_company_properties(self): + """Test that country and company properties work.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + ) + + self.assertEqual(stock.country, self.company.country) + self.assertEqual(stock.company, self.company) + + def test_get_absolute_url(self): + """Test get_absolute_url returns correct URL.""" + stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + ) + + url = stock.get_absolute_url() + self.assertIn(str(stock.uuid), url) + + def test_published_filtering(self): + """Test PublicManager filters unpublished items.""" + published_stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + published=True, + ) + + unpublished_stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="341", + scale=self.scale, + published=False, + ) + + # PublicManager should only return published items + all_stock = RollingStock.objects.all() + + # Note: This test assumes PublicManager is properly configured + # to filter by published=True + self.assertIn(published_stock, all_stock) + + def test_ordering(self): + """Test rolling stock ordering.""" + stock1 = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + ) + + stock2 = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="342", + scale=self.scale, + ) + + stock3 = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="341", + scale=self.scale, + ) + + stocks = list(RollingStock.objects.all()) + self.assertEqual(stocks[0], stock1) # 340 + self.assertEqual(stocks[1], stock3) # 341 + self.assertEqual(stocks[2], stock2) # 342 + + +class RollingStockImageTestCase(TestCase): + """Test cases for RollingStockImage model.""" + + def setUp(self): + """Set up test data.""" + self.company = Company.objects.create(name="RGS", country="US") + self.stock_type = RollingStockType.objects.create( + type="Steam Locomotive", + category="locomotive", + order=1, + ) + self.rolling_class = RollingClass.objects.create( + identifier="C-19", + type=self.stock_type, + company=self.company, + ) + self.scale = Scale.objects.create( + scale="HOn3", + ratio="1:87", + tracks=10.5, + ) + self.stock = RollingStock.objects.create( + rolling_class=self.rolling_class, + road_number="340", + scale=self.scale, + ) + + def test_image_ordering(self): + """Test that images are ordered by the order field.""" + # Note: Actual image upload testing would require test files + # This test validates the relationship exists + self.assertEqual(self.stock.image.count(), 0) + + # The image model should have an order field + self.assertTrue(hasattr(RollingStockImage, "order"))