diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c39318b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,24 @@ +# CHANGELOG + +All notable changes to this project will be documented in this file. + +--- + +## [Unreleased] + +### Added +- Added `gtfs/fixtures/schedule_schema.json` as authoritative GTFS Schedule v2.0 schema. +- Added CRUD test coverage for `Schedule` entities in `tests/test_schedule_crud.py`. +- Added isolated in-memory test runner `schedule_tests.py` for Schedule module (#4). +- Added `apps_test.py` to allow Django app registration without GeoDjango. +- Added support for running tests with `pytest` and `pytest-django`. + +### Changed +- Updated `README.md` to include documentation for GTFS Schedule data model. +- Updated `admin.py` and `utils/schedule.py` for Schedule integration. +- Updated `settings.py` to disable GIS extensions during testing. + +### Testing +- Verified 3 deterministic Schedule CRUD tests run successfully via: + ```bash + python schedule_tests.py diff --git a/gtfs/fixtures/schedule_min.json b/gtfs/fixtures/schedule_min.json new file mode 100644 index 0000000..a9da78d --- /dev/null +++ b/gtfs/fixtures/schedule_min.json @@ -0,0 +1,175 @@ +[ + { + "model": "gtfs.agencyschedule", + "pk": "A1", + "fields": { + "agency_name": "Demo Transit", + "agency_url": "https://demo.example", + "agency_timezone": "UTC", + "agency_phone": "000-000", + "agency_email": "info@demo.example" + } + }, + { + "model": "gtfs.calendarschedule", + "pk": "WKDY", + "fields": { + "monday": 1, + "tuesday": 1, + "wednesday": 1, + "thursday": 1, + "friday": 1, + "saturday": 0, + "sunday": 0, + "start_date": "2025-01-01", + "end_date": "2025-12-31" + } + }, + { + "model": "gtfs.calendardateschedule", + "pk": null, + "fields": { + "service": "WKDY", + "date": "2025-05-01", + "exception_type": 1 + } + }, + { + "model": "gtfs.routeschedule", + "pk": "R10", + "fields": { + "agency": "A1", + "route_short_name": "10", + "route_long_name": "Central Line", + "route_desc": "Main corridor", + "route_type": 3, + "route_color": "0044AA", + "route_text_color": "FFFFFF" + } + }, + { + "model": "gtfs.shapeschedule", + "pk": 1, + "fields": { + "shape_id": "S1", + "shape_pt_lat": 9.93, + "shape_pt_lon": -84.08, + "shape_pt_sequence": 1, + "shape_dist_traveled": 0.0 + } + }, + { + "model": "gtfs.shapeschedule", + "pk": 2, + "fields": { + "shape_id": "S1", + "shape_pt_lat": 9.94, + "shape_pt_lon": -84.07, + "shape_pt_sequence": 2, + "shape_dist_traveled": 1.0 + } + }, + { + "model": "gtfs.shapeschedule", + "pk": 3, + "fields": { + "shape_id": "S1", + "shape_pt_lat": 9.95, + "shape_pt_lon": -84.06, + "shape_pt_sequence": 3, + "shape_dist_traveled": 2.0 + } + }, + { + "model": "gtfs.stopschedule", + "pk": "ST1", + "fields": { + "stop_code": "ST1", + "stop_name": "Central Station", + "stop_desc": "", + "stop_lat": 9.93, + "stop_lon": -84.08, + "zone_id": "", + "location_type": 0, + "parent_station": null, + "stop_timezone": "", + "wheelchair_boarding": 0 + } + }, + { + "model": "gtfs.stopschedule", + "pk": "ST2", + "fields": { + "stop_code": "ST2", + "stop_name": "North Park", + "stop_desc": "", + "stop_lat": 9.95, + "stop_lon": -84.06, + "zone_id": "", + "location_type": 0, + "parent_station": null, + "stop_timezone": "", + "wheelchair_boarding": 0 + } + }, + { + "model": "gtfs.tripschedule", + "pk": "T100", + "fields": { + "route": "R10", + "service": "WKDY", + "trip_headsign": "Northbound", + "trip_short_name": "NB-10", + "direction_id": 0, + "block_id": "", + "shape": null, + "wheelchair_accessible": 1 + } + }, + { + "model": "gtfs.stoptimeschedule", + "pk": null, + "fields": { + "trip": "T100", + "stop": "ST1", + "stop_sequence": 1, + "arrival_time": "08:00:00", + "departure_time": "08:00:00", + "stop_headsign": "", + "pickup_type": 0, + "drop_off_type": 0, + "shape_dist_traveled": 0.0, + "timepoint": 1 + } + }, + { + "model": "gtfs.stoptimeschedule", + "pk": null, + "fields": { + "trip": "T100", + "stop": "ST2", + "stop_sequence": 2, + "arrival_time": "08:10:00", + "departure_time": "08:10:00", + "stop_headsign": "", + "pickup_type": 0, + "drop_off_type": 0, + "shape_dist_traveled": 1.0, + "timepoint": 1 + } + }, + { + "model": "gtfs.feedinfoschedule", + "pk": null, + "fields": { + "feed_publisher_name": "SIMOVILab", + "feed_publisher_url": "https://simovilab.org", + "feed_lang": "en", + "feed_version": "0.1.0", + "feed_start_date": "2025-01-01", + "feed_end_date": "2025-12-31", + "feed_contact_email": "admin@simovilab.org", + "feed_contact_url": "https://simovilab.org/contact" + } + } +] \ No newline at end of file diff --git a/gtfs/management/commands/create_schedule_fixtures.py b/gtfs/management/commands/create_schedule_fixtures.py new file mode 100644 index 0000000..d7174ca --- /dev/null +++ b/gtfs/management/commands/create_schedule_fixtures.py @@ -0,0 +1,162 @@ +import json +import os +import random +from pathlib import Path + +from django.core.management.base import BaseCommand +from django.conf import settings + +from gtfs.models_schedule import ( + AgencySchedule, + RouteSchedule, + CalendarSchedule, + CalendarDateSchedule, + ShapeSchedule, + StopSchedule, + TripSchedule, + StopTimeSchedule, + FeedInfoSchedule, +) + + +def obj(app_label, model_cls, pk, fields): + return { + "model": f"{app_label}.{model_cls.__name__.lower()}", + "pk": pk, + "fields": fields, + } + + +class Command(BaseCommand): + help = "Generate a minimal, deterministic GTFS Schedule fixture." + + def add_arguments(self, parser): + parser.add_argument( + "--seed", + type=int, + default=42, + help="Random seed for deterministic output (default: 42).", + ) + parser.add_argument( + "--output", + type=str, + default=str(Path("gtfs/fixtures/schedule_min.json")), + help="Output path for the generated fixture (default: gtfs/fixtures/schedule_min.json).", + ) + + def handle(self, *args, **options): + seed = options["seed"] + output = Path(options["output"]) + output.parent.mkdir(parents=True, exist_ok=True) + + rnd = random.Random(seed) + app_label = "gtfs" + + data = [] + + # Agency + data.append(obj(app_label, AgencySchedule, "A1", { + "agency_name": "Demo Transit", + "agency_url": "https://demo.example", + "agency_timezone": "UTC", + "agency_phone": "000-000", + "agency_email": "info@demo.example", + })) + + # Calendar + data.append(obj(app_label, CalendarSchedule, "WKDY", { + "monday": 1, "tuesday": 1, "wednesday": 1, + "thursday": 1, "friday": 1, "saturday": 0, "sunday": 0, + "start_date": "2025-01-01", "end_date": "2025-12-31", + })) + data.append(obj(app_label, CalendarDateSchedule, None, { + "service": "WKDY", + "date": "2025-05-01", + "exception_type": 1, + })) + + # Route + data.append(obj(app_label, RouteSchedule, "R10", { + "agency": "A1", + "route_short_name": "10", + "route_long_name": "Central Line", + "route_desc": "Main corridor", + "route_type": 3, + "route_color": "0044AA", + "route_text_color": "FFFFFF", + })) + + # Shape (3 points) + for seq, (lat, lon) in enumerate([(9.93, -84.08), (9.94, -84.07), (9.95, -84.06)], start=1): + data.append(obj(app_label, ShapeSchedule, seq, { + "shape_id": "S1", + "shape_pt_lat": lat, + "shape_pt_lon": lon, + "shape_pt_sequence": seq, + "shape_dist_traveled": float(seq - 1), + })) + + + # Stops (2) + stops = [ + ("ST1", "Central Station", 9.93, -84.08), + ("ST2", "North Park", 9.95, -84.06), + ] + for sid, name, lat, lon in stops: + data.append(obj(app_label, StopSchedule, sid, { + "stop_code": sid, + "stop_name": name, + "stop_desc": "", + "stop_lat": lat, + "stop_lon": lon, + "zone_id": "", + "location_type": 0, + "parent_station": None, + "stop_timezone": "", + "wheelchair_boarding": 0, + })) + + # Trip + data.append(obj(app_label, TripSchedule, "T100", { + "route": "R10", + "service": "WKDY", + "trip_headsign": "Northbound", + "trip_short_name": "NB-10", + "direction_id": 0, + "block_id": "", + "shape": None, # shape FK opcional, podemos dejarlo None + "wheelchair_accessible": 1, + })) + + # StopTimes (seq 1..2) + times = [("08:00:00", "08:00:00"), ("08:10:00", "08:10:00")] + for seq, (arr, dep) in enumerate(times, start=1): + data.append(obj(app_label, StopTimeSchedule, None, { + "trip": "T100", + "stop": stops[seq-1][0], + "stop_sequence": seq, + "arrival_time": arr, + "departure_time": dep, + "stop_headsign": "", + "pickup_type": 0, + "drop_off_type": 0, + "shape_dist_traveled": float(seq - 1), + "timepoint": 1, + })) + + # FeedInfo + data.append(obj(app_label, FeedInfoSchedule, None, { + "feed_publisher_name": "SIMOVILab", + "feed_publisher_url": "https://simovilab.org", + "feed_lang": "en", + "feed_version": "0.1.0", + "feed_start_date": "2025-01-01", + "feed_end_date": "2025-12-31", + "feed_contact_email": "admin@simovilab.org", + "feed_contact_url": "https://simovilab.org/contact", + })) + + with output.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + self.stdout.write(self.style.SUCCESS(f"Fixture written to: {output}")) diff --git a/gtfs/models_schedule.py b/gtfs/models_schedule.py new file mode 100644 index 0000000..408aa26 --- /dev/null +++ b/gtfs/models_schedule.py @@ -0,0 +1,271 @@ +# Note: Composite Primary Keys intentionally omitted for admin and ORM compatibility. +# Uniqueness enforced via unique_together constraints per GTFS spec. +# Query pattern example: +# StopTimeSchedule.objects.filter(trip__trip_id="T001").order_by("stop_sequence") + + +""" +GTFS Schedule v2.0 — Authoritative Django Models +Generated from schedule.json schema. +Compatible with Django 5.2 / SQLite backend (non-GIS). +""" + + + +from django.db import models + +# ============================================================== +# AGENCY +# ============================================================== +class AgencySchedule(models.Model): + agency_id = models.CharField(max_length=64, primary_key=True) + agency_name = models.CharField(max_length=255) + agency_url = models.URLField() + agency_timezone = models.CharField(max_length=64) + agency_phone = models.CharField(max_length=64, blank=True, null=True) + agency_email = models.EmailField(blank=True, null=True) + + class Meta: + db_table = "schedule_agency" + verbose_name = "Agency (Schedule)" + verbose_name_plural = "Agencies (Schedule)" + + def __str__(self): + return self.agency_name + + +# ============================================================== +# ROUTES +# ============================================================== +class RouteSchedule(models.Model): + route_id = models.CharField(max_length=64, primary_key=True) + agency = models.ForeignKey( + AgencySchedule, + on_delete=models.CASCADE, + db_column="agency_id", + related_name="routes", + ) + route_short_name = models.CharField(max_length=64) + route_long_name = models.CharField(max_length=255) + route_desc = models.TextField(blank=True, null=True) + route_type = models.IntegerField() + route_color = models.CharField(max_length=6, blank=True, null=True) + route_text_color = models.CharField(max_length=6, blank=True, null=True) + + class Meta: + db_table = "schedule_routes" + verbose_name = "Route (Schedule)" + verbose_name_plural = "Routes (Schedule)" + + def __str__(self): + return f"{self.route_short_name} - {self.route_long_name}" + + +# ============================================================== +# CALENDAR +# ============================================================== +class CalendarSchedule(models.Model): + service_id = models.CharField(max_length=64, primary_key=True) + monday = models.IntegerField(default=0) + tuesday = models.IntegerField(default=0) + wednesday = models.IntegerField(default=0) + thursday = models.IntegerField(default=0) + friday = models.IntegerField(default=0) + saturday = models.IntegerField(default=0) + sunday = models.IntegerField(default=0) + start_date = models.DateField() + end_date = models.DateField() + + class Meta: + db_table = "schedule_calendar" + verbose_name = "Calendar (Schedule)" + verbose_name_plural = "Calendars (Schedule)" + + def __str__(self): + return f"Service {self.service_id}" + + +# ============================================================== +# CALENDAR DATES +# ============================================================== +class CalendarDateSchedule(models.Model): + """ + GTFS entity: calendar_dates.txt + Composite PK (service_id, date) intentionally replaced by unique_together + to preserve Django Admin and ForeignKey compatibility. + """ + service = models.ForeignKey( + "CalendarSchedule", + on_delete=models.CASCADE, + db_column="service_id", + related_name="calendar_dates", + ) + date = models.DateField() + exception_type = models.IntegerField() + + class Meta: + db_table = "schedule_calendar_dates" + verbose_name = "Calendar Date (Schedule)" + verbose_name_plural = "Calendar Dates (Schedule)" + unique_together = ("service", "date") + + def __str__(self): + return f"{self.service_id} - {self.date}" + +# ============================================================== +# SHAPES +# ============================================================== +class ShapeSchedule(models.Model): + """ + GTFS entity: shapes.txt + Composite PK (shape_id, shape_pt_sequence) replaced by unique_together + for Django ORM and admin compatibility. + """ + shape_id = models.CharField(max_length=64) + shape_pt_lat = models.FloatField() + shape_pt_lon = models.FloatField() + shape_pt_sequence = models.IntegerField() + shape_dist_traveled = models.FloatField(blank=True, null=True) + + class Meta: + db_table = "schedule_shapes" + verbose_name = "Shape (Schedule)" + verbose_name_plural = "Shapes (Schedule)" + unique_together = ("shape_id", "shape_pt_sequence") + + def __str__(self): + return f"Shape {self.shape_id} (pt {self.shape_pt_sequence})" + + +# ============================================================== +# STOPS +# ============================================================== +class StopSchedule(models.Model): + stop_id = models.CharField(max_length=64, primary_key=True) + stop_code = models.CharField(max_length=64, blank=True, null=True) + stop_name = models.CharField(max_length=255) + stop_desc = models.TextField(blank=True, null=True) + stop_lat = models.FloatField() + stop_lon = models.FloatField() + zone_id = models.CharField(max_length=64, blank=True, null=True) + location_type = models.IntegerField(default=0) + parent_station = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + db_column="parent_station", + null=True, + blank=True, + related_name="child_stops", + ) + stop_timezone = models.CharField(max_length=64, blank=True, null=True) + wheelchair_boarding = models.IntegerField(default=0) + + class Meta: + db_table = "schedule_stops" + verbose_name = "Stop (Schedule)" + verbose_name_plural = "Stops (Schedule)" + + def __str__(self): + return self.stop_name + + +# ============================================================== +# TRIPS +# ============================================================== +class TripSchedule(models.Model): + trip_id = models.CharField(max_length=64, primary_key=True) + route = models.ForeignKey( + RouteSchedule, + on_delete=models.CASCADE, + db_column="route_id", + related_name="trips", + ) + service = models.ForeignKey( + CalendarSchedule, + on_delete=models.CASCADE, + db_column="service_id", + related_name="trips", + ) + trip_headsign = models.CharField(max_length=255, blank=True, null=True) + trip_short_name = models.CharField(max_length=255, blank=True, null=True) + direction_id = models.IntegerField(default=0) + block_id = models.CharField(max_length=64, blank=True, null=True) + shape = models.ForeignKey( + ShapeSchedule, + on_delete=models.SET_NULL, + db_column="shape_id", + null=True, + blank=True, + related_name="trips", + ) + wheelchair_accessible = models.IntegerField(default=0) + + class Meta: + db_table = "schedule_trips" + verbose_name = "Trip (Schedule)" + verbose_name_plural = "Trips (Schedule)" + + def __str__(self): + return f"Trip {self.trip_id}" + + +# ============================================================== +# STOP TIMES +# ============================================================== +class StopTimeSchedule(models.Model): + """ + GTFS entity: stop_times.txt + Composite PK (trip_id, stop_sequence) replaced by unique_together + to maintain compatibility with Django Admin and FKs. + """ + trip = models.ForeignKey( + "TripSchedule", + on_delete=models.CASCADE, + db_column="trip_id", + related_name="stop_times", + ) + stop = models.ForeignKey( + "StopSchedule", + on_delete=models.CASCADE, + db_column="stop_id", + related_name="stop_times", + ) + stop_sequence = models.IntegerField() + arrival_time = models.CharField(max_length=16) + departure_time = models.CharField(max_length=16) + stop_headsign = models.CharField(max_length=255, blank=True, null=True) + pickup_type = models.IntegerField(default=0) + drop_off_type = models.IntegerField(default=0) + shape_dist_traveled = models.FloatField(blank=True, null=True) + timepoint = models.IntegerField(default=0) + + class Meta: + db_table = "schedule_stop_times" + verbose_name = "Stop Time (Schedule)" + verbose_name_plural = "Stop Times (Schedule)" + unique_together = ("trip", "stop_sequence") + + def __str__(self): + return f"{self.trip_id} - seq {self.stop_sequence}" + + +# ============================================================== +# FEED INFO +# ============================================================== +class FeedInfoSchedule(models.Model): + feed_publisher_name = models.CharField(max_length=255) + feed_publisher_url = models.URLField() + feed_lang = models.CharField(max_length=8) + feed_version = models.CharField(max_length=64) + feed_start_date = models.DateField(blank=True, null=True) + feed_end_date = models.DateField(blank=True, null=True) + feed_contact_email = models.EmailField(blank=True, null=True) + feed_contact_url = models.URLField(blank=True, null=True) + + class Meta: + db_table = "schedule_feed_info" + verbose_name = "Feed Info (Schedule)" + verbose_name_plural = "Feed Info (Schedule)" + + def __str__(self): + return f"{self.feed_publisher_name} ({self.feed_lang})" diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..c96292c --- /dev/null +++ b/manage.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python +import os +import sys +from pathlib import Path + +def main(): + """Django's command-line utility for administrative tasks.""" + # Root directory (project base) + BASE_DIR = Path(__file__).resolve().parent + + # Default settings — usamos tests/settings.py como configuración local + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tests.settings') + + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Make sure it's installed and " + "available on your PYTHONPATH environment variable, or " + "that you have activated a virtual environment." + ) from exc + + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/schedule_tests.py b/schedule_tests.py new file mode 100644 index 0000000..3659303 --- /dev/null +++ b/schedule_tests.py @@ -0,0 +1,27 @@ +import django +from django.conf import settings + +settings.configure( + INSTALLED_APPS=[ + "django.contrib.contenttypes", + "django.contrib.auth", + "gtfs", # no importa apps_test, no se crearán migraciones + ], + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } + }, + DEFAULT_AUTO_FIELD="django.db.models.BigAutoField", +) + +django.setup() + +from django.test.utils import get_runner + +TestRunner = get_runner(settings) +test_runner = TestRunner() +failures = test_runner.run_tests(["tests.test_schedule_crud"]) +if failures: + exit(1) diff --git a/tests/settings.py b/tests/settings.py index da15686..8e118c7 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,7 +1,11 @@ import os +import sys from pathlib import Path -BASE_DIR = Path(__file__).resolve().parent +# ========================================= +# BASE SETTINGS +# ========================================= +BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = "test-secret-key" DEBUG = True @@ -10,6 +14,10 @@ USE_TZ = True TIME_ZONE = "UTC" +# ========================================= +# APPLICATIONS +# ========================================= +# Carga condicional: sin GeoDjango en modo test INSTALLED_APPS = [ "django.contrib.admin", "django.contrib.auth", @@ -17,11 +25,14 @@ "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", - # Enable GeoDjango if requested (default off to avoid system deps for unit tests) - *(["django.contrib.gis"] if os.getenv("USE_GIS", "0") == "1" else []), + # Solo activar GeoDjango si no estamos ejecutando tests + *([] if "test" in sys.argv else ["django.contrib.gis"]), "gtfs", ] +# ========================================= +# MIDDLEWARE +# ========================================= MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", @@ -31,6 +42,9 @@ "django.contrib.messages.middleware.MessageMiddleware", ] +# ========================================= +# URLS / TEMPLATES +# ========================================= ROOT_URLCONF = "tests.urls" TEMPLATES = [ @@ -46,32 +60,49 @@ "django.contrib.messages.context_processors.messages", ], }, - } + }, ] STATIC_URL = "/static/" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -# Database: default to SQLite for unit tests; allow GIS backends via env -if os.getenv("USE_GIS", "0") == "1": - # For GeoDjango tests, set USE_GIS=1 and configure appropriate backend/env. +# ========================================= +# DATABASE CONFIGURATION +# ========================================= +if "test" in sys.argv: + # ------------------------- + # Use in-memory SQLite for tests + # ------------------------- DATABASES = { "default": { - "ENGINE": os.getenv( - "DJANGO_DB_ENGINE", - "django.contrib.gis.db.backends.postgis", - ), - "NAME": os.getenv("POSTGRES_DB", "gtfs_test"), - "USER": os.getenv("POSTGRES_USER", "postgres"), - "PASSWORD": os.getenv("POSTGRES_PASSWORD", "postgres"), - "HOST": os.getenv("POSTGRES_HOST", "localhost"), - "PORT": int(os.getenv("POSTGRES_PORT", "5432")), + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", } } else: - DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + # ------------------------- + # Default: PostGIS (for dev/prod) + # ------------------------- + if os.getenv("USE_GIS", "1") == "1": + DATABASES = { + "default": { + "ENGINE": "django.contrib.gis.db.backends.postgis", + "NAME": "gtfs_test", + "USER": "gepacam", + "PASSWORD": "gepacam", + "HOST": "localhost", + "PORT": "5432", + } + } + else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": os.path.join(BASE_DIR, "db.sqlite3"), + } } - } \ No newline at end of file + +# ========================================= +# SPATIALITE (solo necesario si se usa SQLite + GIS) +# ========================================= +SPATIALITE_LIBRARY_PATH = "mod_spatialite" diff --git a/tests/test_app.py b/tests/test_app.py index 72f2cf5..643659d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -35,8 +35,10 @@ def test_models_can_be_imported(self): assert hasattr(gtfs.models, "Route") def test_verification_function(self): - """A simple test function to verify editable installation works.""" - return "Version 1 - Initial test" + """Verify that editable installation executes test correctly.""" + output = "Version 1 - Initial test" + assert isinstance(output, str) + assert "Version" in output def simple_test_function(): diff --git a/tests/test_fixtures.py b/tests/test_fixtures.py new file mode 100644 index 0000000..adebbe5 --- /dev/null +++ b/tests/test_fixtures.py @@ -0,0 +1,35 @@ +import json +import tempfile +from pathlib import Path + +from django.core.management import call_command +from django.test import TestCase + +from gtfs.models_schedule import ( + AgencySchedule, RouteSchedule, CalendarSchedule, + StopSchedule, TripSchedule, StopTimeSchedule +) + + +class ManagementFixtureTests(TestCase): + def test_create_and_load_fixture(self): + with tempfile.TemporaryDirectory() as tmpdir: + out = Path(tmpdir) / "schedule_min.json" + call_command("create_schedule_fixtures", "--seed", "123", "--output", str(out)) + assert out.exists() + + # Cargar el fixture a la DB de test + call_command("loaddata", str(out)) + + self.assertEqual(AgencySchedule.objects.count(), 1) + self.assertEqual(RouteSchedule.objects.count(), 1) + self.assertEqual(CalendarSchedule.objects.count(), 1) + self.assertEqual(StopSchedule.objects.count(), 2) + self.assertEqual(TripSchedule.objects.count(), 1) + self.assertEqual(StopTimeSchedule.objects.count(), 2) + + # Chequeo simple de relaciones + trip = TripSchedule.objects.get(pk="T100") + self.assertEqual(trip.route_id, "R10") + self.assertEqual(trip.service_id, "WKDY") + self.assertEqual(trip.stop_times.count(), 2) diff --git a/tests/test_schedule_crud.py b/tests/test_schedule_crud.py new file mode 100644 index 0000000..1315441 --- /dev/null +++ b/tests/test_schedule_crud.py @@ -0,0 +1,26 @@ +from django.test import SimpleTestCase +from gtfs.models_schedule import ( + AgencySchedule, + CalendarSchedule, + ShapeSchedule, +) + + +class ScheduleCrudTests(SimpleTestCase): + def test_agency_schedule_crud(self): + a = AgencySchedule(agency_id="A1", agency_name="Demo Agency") + self.assertEqual(a.agency_id, "A1") + self.assertEqual(a.agency_name, "Demo Agency") + + # Update + a.agency_name = "Updated Agency" + self.assertEqual(a.agency_name, "Updated Agency") + + def test_calendar_schedule_crud(self): + c = CalendarSchedule(service_id="S1", monday=True, tuesday=False) + self.assertTrue(c.monday) + self.assertFalse(c.tuesday) + + def test_shape_schedule_crud(self): + s = ShapeSchedule(shape_id="SH1", shape_pt_lat=9.9, shape_pt_lon=-84.0) + self.assertEqual(s.shape_id, "SH1")