From 84bf3d5d064433b56c47843584fbb619cc78e447 Mon Sep 17 00:00:00 2001 From: Drew Dara-Abrams Date: Fri, 21 Nov 2025 22:52:32 -0800 Subject: [PATCH 01/17] adding database, copier, validation support for adopted GTFS-Flex and two proposed columns https://github.com/interline-io/calact-network-analysis-tool/issues/211 --- adapters/direct/reader.go | 104 ++++++--- adapters/empty/reader.go | 28 +++ adapters/multireader/reader.go | 16 ++ copier/copier.go | 11 + gtfs/booking_rule.go | 100 ++++++++ gtfs/gtfs.go | 4 + gtfs/gtfs_flex_test.go | 125 ++++++++++ gtfs/location.go | 54 +++++ gtfs/location_group.go | 29 +++ gtfs/location_group_stop.go | 21 ++ gtfs/stop_time.go | 71 ++++++ gtfs/stop_time_flex_validation.go | 115 ++++++++++ gtfs/stop_time_flex_validation_test.go | 190 ++++++++++++++++ rules/flex_geography_id_unique.go | 102 +++++++++ rules/flex_geography_id_unique_test.go | 111 +++++++++ rules/flex_location_geometry.go | 64 ++++++ rules/flex_location_group_empty.go | 57 +++++ rules/flex_stop_location_type.go | 88 +++++++ rules/flex_validation_test.go | 215 ++++++++++++++++++ rules/flex_zone_id_conditional.go | 57 +++++ .../20251121000001_gtfs_flex.up.pgsql | 197 ++++++++++++++++ schema/sqlite/sqlite.sql | 88 ++++++- tlcsv/geojson.go | 141 ++++++++++++ tlcsv/geojson_test.go | 208 +++++++++++++++++ tlcsv/reader.go | 32 +++ tldb/reader.go | 16 ++ 26 files changed, 2214 insertions(+), 30 deletions(-) create mode 100644 gtfs/booking_rule.go create mode 100644 gtfs/gtfs_flex_test.go create mode 100644 gtfs/location.go create mode 100644 gtfs/location_group.go create mode 100644 gtfs/location_group_stop.go create mode 100644 gtfs/stop_time_flex_validation.go create mode 100644 gtfs/stop_time_flex_validation_test.go create mode 100644 rules/flex_geography_id_unique.go create mode 100644 rules/flex_geography_id_unique_test.go create mode 100644 rules/flex_location_geometry.go create mode 100644 rules/flex_location_group_empty.go create mode 100644 rules/flex_stop_location_type.go create mode 100644 rules/flex_validation_test.go create mode 100644 rules/flex_zone_id_conditional.go create mode 100644 schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql create mode 100644 tlcsv/geojson.go create mode 100644 tlcsv/geojson_test.go diff --git a/adapters/direct/reader.go b/adapters/direct/reader.go index aacc38fd6..373de0f51 100644 --- a/adapters/direct/reader.go +++ b/adapters/direct/reader.go @@ -11,34 +11,38 @@ var bufferSize = 1000 // Reader is a mocked up Reader used for testing. type Reader struct { - AgencyList []gtfs.Agency - RouteList []gtfs.Route - TripList []gtfs.Trip - StopList []gtfs.Stop - StopTimeList []gtfs.StopTime - ShapeList []gtfs.Shape - CalendarList []gtfs.Calendar - CalendarDateList []gtfs.CalendarDate - FeedInfoList []gtfs.FeedInfo - FareRuleList []gtfs.FareRule - FareAttributeList []gtfs.FareAttribute - FrequencyList []gtfs.Frequency - TransferList []gtfs.Transfer - LevelList []gtfs.Level - PathwayList []gtfs.Pathway - AttributionList []gtfs.Attribution - TranslationList []gtfs.Translation - AreaList []gtfs.Area - StopAreaList []gtfs.StopArea - FareLegRuleList []gtfs.FareLegRule - FareTransferRuleList []gtfs.FareTransferRule - FareMediaList []gtfs.FareMedia - FareProductList []gtfs.FareProduct - RiderCategoryList []gtfs.RiderCategory - TimeframeList []gtfs.Timeframe - NetworkList []gtfs.Network - RouteNetworkList []gtfs.RouteNetwork - OtherList []tt.Entity + AgencyList []gtfs.Agency + RouteList []gtfs.Route + TripList []gtfs.Trip + StopList []gtfs.Stop + StopTimeList []gtfs.StopTime + ShapeList []gtfs.Shape + CalendarList []gtfs.Calendar + CalendarDateList []gtfs.CalendarDate + FeedInfoList []gtfs.FeedInfo + FareRuleList []gtfs.FareRule + FareAttributeList []gtfs.FareAttribute + FrequencyList []gtfs.Frequency + TransferList []gtfs.Transfer + LevelList []gtfs.Level + PathwayList []gtfs.Pathway + AttributionList []gtfs.Attribution + TranslationList []gtfs.Translation + AreaList []gtfs.Area + StopAreaList []gtfs.StopArea + FareLegRuleList []gtfs.FareLegRule + FareTransferRuleList []gtfs.FareTransferRule + FareMediaList []gtfs.FareMedia + FareProductList []gtfs.FareProduct + RiderCategoryList []gtfs.RiderCategory + TimeframeList []gtfs.Timeframe + NetworkList []gtfs.Network + RouteNetworkList []gtfs.RouteNetwork + LocationGroupList []gtfs.LocationGroup + LocationGroupStopList []gtfs.LocationGroupStop + BookingRuleList []gtfs.BookingRule + LocationList []gtfs.Location + OtherList []tt.Entity } // NewReader returns a new Reader. @@ -432,3 +436,47 @@ func (mr *Reader) RouteNetworks() chan gtfs.RouteNetwork { }() return out } + +func (mr *Reader) LocationGroups() chan gtfs.LocationGroup { + out := make(chan gtfs.LocationGroup, bufferSize) + go func() { + for _, ent := range mr.LocationGroupList { + out <- ent + } + close(out) + }() + return out +} + +func (mr *Reader) LocationGroupStops() chan gtfs.LocationGroupStop { + out := make(chan gtfs.LocationGroupStop, bufferSize) + go func() { + for _, ent := range mr.LocationGroupStopList { + out <- ent + } + close(out) + }() + return out +} + +func (mr *Reader) BookingRules() chan gtfs.BookingRule { + out := make(chan gtfs.BookingRule, bufferSize) + go func() { + for _, ent := range mr.BookingRuleList { + out <- ent + } + close(out) + }() + return out +} + +func (mr *Reader) Locations() chan gtfs.Location { + out := make(chan gtfs.Location, bufferSize) + go func() { + for _, ent := range mr.LocationList { + out <- ent + } + close(out) + }() + return out +} diff --git a/adapters/empty/reader.go b/adapters/empty/reader.go index 1b3dbc4ab..613d8558f 100644 --- a/adapters/empty/reader.go +++ b/adapters/empty/reader.go @@ -139,6 +139,34 @@ func (mr *Reader) RiderCategories() chan gtfs.RiderCategory { return readNullEntities[gtfs.RiderCategory](mr) } +func (mr *Reader) Timeframes() chan gtfs.Timeframe { + return readNullEntities[gtfs.Timeframe](mr) +} + +func (mr *Reader) Networks() chan gtfs.Network { + return readNullEntities[gtfs.Network](mr) +} + +func (mr *Reader) RouteNetworks() chan gtfs.RouteNetwork { + return readNullEntities[gtfs.RouteNetwork](mr) +} + +func (mr *Reader) LocationGroups() chan gtfs.LocationGroup { + return readNullEntities[gtfs.LocationGroup](mr) +} + +func (mr *Reader) LocationGroupStops() chan gtfs.LocationGroupStop { + return readNullEntities[gtfs.LocationGroupStop](mr) +} + +func (mr *Reader) BookingRules() chan gtfs.BookingRule { + return readNullEntities[gtfs.BookingRule](mr) +} + +func (mr *Reader) Locations() chan gtfs.Location { + return readNullEntities[gtfs.Location](mr) +} + func readNullEntities[T any](reader *Reader) chan T { out := make(chan T, bufferSize) go func() { diff --git a/adapters/multireader/reader.go b/adapters/multireader/reader.go index f4704a8b0..1cf256881 100644 --- a/adapters/multireader/reader.go +++ b/adapters/multireader/reader.go @@ -176,6 +176,22 @@ func (mr *Reader) RouteNetworks() chan gtfs.RouteNetwork { return readEntities(mr, func(r adapters.Reader) chan gtfs.RouteNetwork { return r.RouteNetworks() }, setFv[*gtfs.RouteNetwork]) } +func (mr *Reader) LocationGroups() chan gtfs.LocationGroup { + return readEntities(mr, func(r adapters.Reader) chan gtfs.LocationGroup { return r.LocationGroups() }, setFv[*gtfs.LocationGroup]) +} + +func (mr *Reader) LocationGroupStops() chan gtfs.LocationGroupStop { + return readEntities(mr, func(r adapters.Reader) chan gtfs.LocationGroupStop { return r.LocationGroupStops() }, setFv[*gtfs.LocationGroupStop]) +} + +func (mr *Reader) BookingRules() chan gtfs.BookingRule { + return readEntities(mr, func(r adapters.Reader) chan gtfs.BookingRule { return r.BookingRules() }, setFv[*gtfs.BookingRule]) +} + +func (mr *Reader) Locations() chan gtfs.Location { + return readEntities(mr, func(r adapters.Reader) chan gtfs.Location { return r.Locations() }, setFv[*gtfs.Location]) +} + type canSetFV interface { SetFeedVersionID(int) } diff --git a/copier/copier.go b/copier/copier.go index 6da9964e9..9f36f6c0d 100644 --- a/copier/copier.go +++ b/copier/copier.go @@ -291,6 +291,12 @@ func NewCopier(ctx context.Context, reader adapters.Reader, writer adapters.Writ &rules.CalendarDuplicateDates{}, &rules.FareProductRiderCategoryDefaultCheck{}, &rules.TransferStopLocationTypeCheck{}, + // GTFS-Flex validators + &rules.FlexGeographyIDUniqueCheck{}, + &rules.FlexStopLocationTypeCheck{}, + &rules.FlexLocationGroupEmptyCheck{}, + &rules.FlexLocationGeometryCheck{}, + &rules.FlexZoneIDConditionalCheck{}, ) } @@ -612,6 +618,11 @@ func (copier *Copier) Copy(ctx context.Context) (*Result, error) { ) }, copier.copyCalendars, + // GTFS-Flex entities (must come before stop_times for proper reference validation) + func() error { return batchCopy(copier, batchChan(r.BookingRules(), bs, nil)) }, + func() error { return batchCopy(copier, batchChan(r.Locations(), bs, nil)) }, + func() error { return batchCopy(copier, batchChan(r.LocationGroups(), bs, nil)) }, + func() error { return batchCopy(copier, batchChan(r.LocationGroupStops(), bs, nil)) }, copier.copyTripsAndStopTimes, func() error { return batchCopy(copier, batchChan(r.Pathways(), bs, nil)) }, func() error { return batchCopy(copier, batchChan(r.FareAttributes(), bs, nil)) }, diff --git a/gtfs/booking_rule.go b/gtfs/booking_rule.go new file mode 100644 index 000000000..82e10b143 --- /dev/null +++ b/gtfs/booking_rule.go @@ -0,0 +1,100 @@ +package gtfs + +import ( + "github.com/interline-io/transitland-lib/causes" + "github.com/interline-io/transitland-lib/tt" +) + +// BookingRule booking_rules.txt +type BookingRule struct { + BookingRuleID tt.String `csv:",required"` + BookingType tt.Int `csv:",required" enum:"0,1,2"` + PriorNoticeDurationMin tt.Int + PriorNoticeDurationMax tt.Int + PriorNoticeLastDay tt.Int + PriorNoticeLastTime tt.Seconds + PriorNoticeStartDay tt.Int + PriorNoticeStartTime tt.Seconds + PriorNoticeServiceID tt.Key `target:"calendar.txt"` + Message tt.String + PickupMessage tt.String + DropOffMessage tt.String + PhoneNumber tt.String + InfoURL tt.Url + BookingURL tt.Url + tt.BaseEntity +} + +func (ent *BookingRule) EntityKey() string { + return ent.BookingRuleID.Val +} + +func (ent *BookingRule) EntityID() string { + return entID(ent.ID, ent.BookingRuleID.Val) +} + +func (ent *BookingRule) Filename() string { + return "booking_rules.txt" +} + +func (ent *BookingRule) TableName() string { + return "gtfs_booking_rules" +} + +// ConditionalErrors for this Entity. +func (ent *BookingRule) ConditionalErrors() (errs []error) { + bookingType := ent.BookingType.Val + + // booking_type=1: prior_notice_duration_min is required + if bookingType == 1 { + if !ent.PriorNoticeDurationMin.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("prior_notice_duration_min")) + } + // prior_notice_duration_max is optional for booking_type=1 + } + + // booking_type=2: prior_notice_last_day is required + if bookingType == 2 { + if !ent.PriorNoticeLastDay.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("prior_notice_last_day")) + } + // prior_notice_duration_max is forbidden for booking_type=2 + if ent.PriorNoticeDurationMax.Valid { + errs = append(errs, causes.NewConditionallyForbiddenFieldError("prior_notice_duration_max", ent.PriorNoticeDurationMax.String(), "prior_notice_duration_max is forbidden for booking_type=2")) + } + } + + // booking_type=0: prior_notice_duration_max is forbidden + if bookingType == 0 { + if ent.PriorNoticeDurationMax.Valid { + errs = append(errs, causes.NewConditionallyForbiddenFieldError("prior_notice_duration_max", ent.PriorNoticeDurationMax.String(), "prior_notice_duration_max is forbidden for booking_type=0")) + } + // prior_notice_start_day is forbidden for booking_type=0 + if ent.PriorNoticeStartDay.Valid { + errs = append(errs, causes.NewConditionallyForbiddenFieldError("prior_notice_start_day", ent.PriorNoticeStartDay.String(), "prior_notice_start_day is forbidden for booking_type=0")) + } + } + + // prior_notice_last_time requires prior_notice_last_day + if ent.PriorNoticeLastTime.Valid && !ent.PriorNoticeLastDay.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("prior_notice_last_day")) + } + + // prior_notice_start_time requires prior_notice_start_day + if ent.PriorNoticeStartTime.Valid && !ent.PriorNoticeStartDay.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("prior_notice_start_day")) + } + + // prior_notice_service_id is forbidden except for booking_type=2 + if bookingType != 2 && ent.PriorNoticeServiceID.Valid { + errs = append(errs, causes.NewConditionallyForbiddenFieldError("prior_notice_service_id", ent.PriorNoticeServiceID.Val, "prior_notice_service_id is only allowed for booking_type=2")) + } + + // booking_type=1: prior_notice_start_day forbidden if prior_notice_duration_max is defined + if bookingType == 1 && ent.PriorNoticeDurationMax.Valid && ent.PriorNoticeStartDay.Valid { + errs = append(errs, causes.NewConditionallyForbiddenFieldError("prior_notice_start_day", ent.PriorNoticeStartDay.String(), "prior_notice_start_day is forbidden when prior_notice_duration_max is defined for booking_type=1")) + } + + return errs +} + diff --git a/gtfs/gtfs.go b/gtfs/gtfs.go index 48c8fa19c..1ea55bc28 100644 --- a/gtfs/gtfs.go +++ b/gtfs/gtfs.go @@ -39,4 +39,8 @@ type Reader interface { Timeframes() chan Timeframe Networks() chan Network RouteNetworks() chan RouteNetwork + LocationGroups() chan LocationGroup + LocationGroupStops() chan LocationGroupStop + BookingRules() chan BookingRule + Locations() chan Location } diff --git a/gtfs/gtfs_flex_test.go b/gtfs/gtfs_flex_test.go new file mode 100644 index 000000000..a947c2c17 --- /dev/null +++ b/gtfs/gtfs_flex_test.go @@ -0,0 +1,125 @@ +package gtfs + +import ( + "testing" + + "github.com/interline-io/transitland-lib/tt" + "github.com/stretchr/testify/assert" +) + +func TestLocationGroup(t *testing.T) { + lg := LocationGroup{ + LocationGroupID: tt.NewString("lg1"), + LocationGroupName: tt.NewString("Downtown"), + } + assert.Equal(t, "lg1", lg.EntityKey()) + assert.Equal(t, "location_groups.txt", lg.Filename()) + assert.Equal(t, "gtfs_location_groups", lg.TableName()) +} + +func TestLocationGroupStop(t *testing.T) { + lgs := LocationGroupStop{ + LocationGroupID: tt.NewKey("lg1"), + StopID: tt.NewKey("stop1"), + } + assert.Equal(t, "location_group_stops.txt", lgs.Filename()) + assert.Equal(t, "gtfs_location_group_stops", lgs.TableName()) +} + +func TestBookingRule(t *testing.T) { + t.Run("booking_type=0", func(t *testing.T) { + br := BookingRule{ + BookingRuleID: tt.NewString("rule1"), + BookingType: tt.NewInt(0), + } + assert.Equal(t, "rule1", br.EntityKey()) + assert.Equal(t, "booking_rules.txt", br.Filename()) + assert.Equal(t, "gtfs_booking_rules", br.TableName()) + + // booking_type=0: prior_notice_duration_max forbidden + errs := br.ConditionalErrors() + assert.Len(t, errs, 0) + + br.PriorNoticeDurationMax = tt.NewInt(30) + errs = br.ConditionalErrors() + assert.Greater(t, len(errs), 0) + }) + + t.Run("booking_type=1", func(t *testing.T) { + br := BookingRule{ + BookingRuleID: tt.NewString("rule2"), + BookingType: tt.NewInt(1), + } + + // booking_type=1: prior_notice_duration_min required + errs := br.ConditionalErrors() + assert.Greater(t, len(errs), 0) + + br.PriorNoticeDurationMin = tt.NewInt(15) + errs = br.ConditionalErrors() + assert.Len(t, errs, 0) + }) + + t.Run("booking_type=2", func(t *testing.T) { + br := BookingRule{ + BookingRuleID: tt.NewString("rule3"), + BookingType: tt.NewInt(2), + } + + // booking_type=2: prior_notice_last_day required + errs := br.ConditionalErrors() + assert.Greater(t, len(errs), 0) + + br.PriorNoticeLastDay = tt.NewInt(1) + errs = br.ConditionalErrors() + assert.Len(t, errs, 0) + + // booking_type=2: prior_notice_duration_max forbidden + br.PriorNoticeDurationMax = tt.NewInt(30) + errs = br.ConditionalErrors() + assert.Greater(t, len(errs), 0) + }) +} + +func TestLocation(t *testing.T) { + loc := Location{ + LocationID: tt.NewString("loc1"), + StopName: tt.NewString("Flexible Zone"), + StopDesc: tt.NewString("On-demand service area"), + } + assert.Equal(t, "loc1", loc.EntityKey()) + assert.Equal(t, "locations.geojson", loc.Filename()) + assert.Equal(t, "gtfs_locations", loc.TableName()) + + // Geometry is required + errs := loc.ConditionalErrors() + assert.Greater(t, len(errs), 0) +} + +func TestStopTimeFlexFields(t *testing.T) { + st := StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewString("stop1"), + StopSequence: tt.NewInt(1), + StartPickupDropOffWindow: tt.NewSeconds(28800), // 8:00:00 + EndPickupDropOffWindow: tt.NewSeconds(32400), // 9:00:00 + PickupBookingRuleID: tt.NewKey("rule1"), + MeanDurationFactor: tt.NewFloat(1.5), + SafeDurationFactor: tt.NewFloat(2.0), + } + + // Verify fields can be set and retrieved + val, err := st.GetString("start_pickup_drop_off_window") + assert.NoError(t, err) + assert.Equal(t, "08:00:00", val) + + val, err = st.GetString("pickup_booking_rule_id") + assert.NoError(t, err) + assert.Equal(t, "rule1", val) + + val, err = st.GetString("mean_duration_factor") + assert.NoError(t, err) + assert.Contains(t, val, "1.5") +} + + diff --git a/gtfs/location.go b/gtfs/location.go new file mode 100644 index 000000000..13544b7b2 --- /dev/null +++ b/gtfs/location.go @@ -0,0 +1,54 @@ +package gtfs + +import ( + "github.com/interline-io/transitland-lib/causes" + "github.com/interline-io/transitland-lib/tt" +) + +// Location represents a GeoJSON feature from locations.geojson +// Locations define zones using GeoJSON Polygon or MultiPolygon geometries +// where riders can request pickups or drop-offs for flexible services +type Location struct { + // ID is the feature ID from the GeoJSON, shares namespace with stop_id + LocationID tt.String `json:"id" csv:",required"` + // Properties + StopName tt.String `json:"stop_name"` + StopDesc tt.String `json:"stop_desc"` + ZoneID tt.String `json:"zone_id"` + StopURL tt.Url `json:"stop_url"` + // Geometry - can be either Polygon or MultiPolygon + // We store the raw geometry as a Geometry type which can hold either + Geometry tt.Geometry `json:"geometry"` + tt.BaseEntity +} + +func (ent *Location) EntityKey() string { + return ent.LocationID.Val +} + +func (ent *Location) EntityID() string { + return entID(ent.ID, ent.LocationID.Val) +} + +func (ent *Location) Filename() string { + return "locations.geojson" +} + +func (ent *Location) TableName() string { + return "gtfs_locations" +} + +// ConditionalErrors for this Entity. +func (ent *Location) ConditionalErrors() (errs []error) { + // zone_id is conditionally required if fare_rules.txt is defined + // This check would need to be done at a higher level since we don't have + // access to the full feed context here + + // Geometry must be present + if !ent.Geometry.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("geometry")) + } + + return errs +} + diff --git a/gtfs/location_group.go b/gtfs/location_group.go new file mode 100644 index 000000000..30604e3c5 --- /dev/null +++ b/gtfs/location_group.go @@ -0,0 +1,29 @@ +package gtfs + +import ( + "github.com/interline-io/transitland-lib/tt" +) + +// LocationGroup location_groups.txt +type LocationGroup struct { + LocationGroupID tt.String `csv:",required"` + LocationGroupName tt.String + tt.BaseEntity +} + +func (ent *LocationGroup) EntityKey() string { + return ent.LocationGroupID.Val +} + +func (ent *LocationGroup) EntityID() string { + return entID(ent.ID, ent.LocationGroupID.Val) +} + +func (ent *LocationGroup) Filename() string { + return "location_groups.txt" +} + +func (ent *LocationGroup) TableName() string { + return "gtfs_location_groups" +} + diff --git a/gtfs/location_group_stop.go b/gtfs/location_group_stop.go new file mode 100644 index 000000000..202de559b --- /dev/null +++ b/gtfs/location_group_stop.go @@ -0,0 +1,21 @@ +package gtfs + +import ( + "github.com/interline-io/transitland-lib/tt" +) + +// LocationGroupStop location_group_stops.txt +type LocationGroupStop struct { + LocationGroupID tt.Key `csv:",required" target:"location_groups.txt"` + StopID tt.Key `csv:",required" target:"stops.txt"` + tt.BaseEntity +} + +func (ent *LocationGroupStop) Filename() string { + return "location_group_stops.txt" +} + +func (ent *LocationGroupStop) TableName() string { + return "gtfs_location_group_stops" +} + diff --git a/gtfs/stop_time.go b/gtfs/stop_time.go index 8f5afd0ee..96e34babd 100644 --- a/gtfs/stop_time.go +++ b/gtfs/stop_time.go @@ -22,6 +22,17 @@ type StopTime struct { ShapeDistTraveled tt.Float Timepoint tt.Int Interpolated tt.Int `csv:"-"` // interpolated times: 0 for provided, 1 interpolated // TODO: 1 for shape, 2 for straight-line + // GTFS-Flex fields (officially adopted) + StartPickupDropOffWindow tt.Seconds + EndPickupDropOffWindow tt.Seconds + PickupBookingRuleID tt.Key `target:"booking_rules.txt"` + DropOffBookingRuleID tt.Key `target:"booking_rules.txt"` + // GTFS-Flex proposed fields (not yet formally adopted, may change) + // See: https://github.com/MobilityData/gtfs-flex/blob/master/spec/reference.md + MeanDurationFactor tt.Float // proposed gtfs-flex + MeanDurationOffset tt.Float // proposed gtfs-flex + SafeDurationFactor tt.Float // proposed gtfs-flex + SafeDurationOffset tt.Float // proposed gtfs-flex tt.MinEntity tt.ErrorEntity tt.ExtraEntity @@ -61,12 +72,20 @@ func (ent *StopTime) Errors() []error { return errs } +// ConditionalErrors for StopTime - includes GTFS-Flex validation +func (ent *StopTime) ConditionalErrors() (errs []error) { + errs = append(errs, ent.conditionalErrorsFlex()...) + return errs +} + // UpdateKeys updates Entity references. func (ent *StopTime) UpdateKeys(emap *tt.EntityMap) error { // Don't use reflection based path return tt.FirstError( tt.TrySetField(emap.UpdateKey(&ent.TripID, "trips.txt"), "trip_id"), tt.TrySetField(emap.UpdateKey(&ent.StopID, "stops.txt"), "stop_id"), + tt.TrySetField(emap.UpdateKey(&ent.PickupBookingRuleID, "booking_rules.txt"), "pickup_booking_rule_id"), + tt.TrySetField(emap.UpdateKey(&ent.DropOffBookingRuleID, "booking_rules.txt"), "drop_off_booking_rule_id"), ) } @@ -101,6 +120,30 @@ func (ent *StopTime) GetString(key string) (string, error) { v = ent.ContinuousPickup.String() case "continuous_drop_off": v = ent.ContinuousDropOff.String() + case "start_pickup_drop_off_window": + v = ent.StartPickupDropOffWindow.String() + case "end_pickup_drop_off_window": + v = ent.EndPickupDropOffWindow.String() + case "pickup_booking_rule_id": + v = ent.PickupBookingRuleID.Val + case "drop_off_booking_rule_id": + v = ent.DropOffBookingRuleID.Val + case "mean_duration_factor": + if ent.MeanDurationFactor.Valid { + v = fmt.Sprintf("%0.5f", ent.MeanDurationFactor.Val) + } + case "mean_duration_offset": + if ent.MeanDurationOffset.Valid { + v = fmt.Sprintf("%0.5f", ent.MeanDurationOffset.Val) + } + case "safe_duration_factor": + if ent.SafeDurationFactor.Valid { + v = fmt.Sprintf("%0.5f", ent.SafeDurationFactor.Val) + } + case "safe_duration_offset": + if ent.SafeDurationOffset.Valid { + v = fmt.Sprintf("%0.5f", ent.SafeDurationOffset.Val) + } default: return v, fmt.Errorf("unknown key: %s", key) } @@ -155,6 +198,34 @@ func (ent *StopTime) SetString(key, value string) error { if err := ent.Timepoint.Scan(hi); err != nil { perr = causes.NewFieldParseError("timepoint", hi) } + case "start_pickup_drop_off_window": + if err := ent.StartPickupDropOffWindow.Scan(hi); err != nil { + perr = causes.NewFieldParseError("start_pickup_drop_off_window", hi) + } + case "end_pickup_drop_off_window": + if err := ent.EndPickupDropOffWindow.Scan(hi); err != nil { + perr = causes.NewFieldParseError("end_pickup_drop_off_window", hi) + } + case "pickup_booking_rule_id": + ent.PickupBookingRuleID.Set(hi) + case "drop_off_booking_rule_id": + ent.DropOffBookingRuleID.Set(hi) + case "mean_duration_factor": + if err := ent.MeanDurationFactor.Scan(hi); err != nil { + perr = causes.NewFieldParseError("mean_duration_factor", hi) + } + case "mean_duration_offset": + if err := ent.MeanDurationOffset.Scan(hi); err != nil { + perr = causes.NewFieldParseError("mean_duration_offset", hi) + } + case "safe_duration_factor": + if err := ent.SafeDurationFactor.Scan(hi); err != nil { + perr = causes.NewFieldParseError("safe_duration_factor", hi) + } + case "safe_duration_offset": + if err := ent.SafeDurationOffset.Scan(hi); err != nil { + perr = causes.NewFieldParseError("safe_duration_offset", hi) + } default: ent.SetExtra(key, hi) } diff --git a/gtfs/stop_time_flex_validation.go b/gtfs/stop_time_flex_validation.go new file mode 100644 index 000000000..3832e9c20 --- /dev/null +++ b/gtfs/stop_time_flex_validation.go @@ -0,0 +1,115 @@ +package gtfs + +import ( + "fmt" + + "github.com/interline-io/transitland-lib/causes" +) + +// ConditionalErrors for StopTime - GTFS-Flex specific validation +func (ent *StopTime) conditionalErrorsFlex() (errs []error) { + // 1. Validate pickup/drop_off window consistency + hasStartWindow := ent.StartPickupDropOffWindow.Valid + hasEndWindow := ent.EndPickupDropOffWindow.Valid + + if hasStartWindow && !hasEndWindow { + errs = append(errs, causes.NewConditionallyRequiredFieldError("end_pickup_drop_off_window")) + } + if hasEndWindow && !hasStartWindow { + errs = append(errs, causes.NewConditionallyRequiredFieldError("start_pickup_drop_off_window")) + } + + // 2. If both windows are present, end must be >= start + if hasStartWindow && hasEndWindow { + if ent.EndPickupDropOffWindow.Val < ent.StartPickupDropOffWindow.Val { + errs = append(errs, causes.NewInvalidFieldError( + "end_pickup_drop_off_window", + fmt.Sprintf("%d", ent.EndPickupDropOffWindow.Val), + fmt.Errorf("must be greater than or equal to start_pickup_drop_off_window (%d)", ent.StartPickupDropOffWindow.Val), + )) + } + } + + // 3. Validate time windows vs fixed times + // If using time windows, arrival_time and departure_time must be empty + if hasStartWindow || hasEndWindow { + if ent.ArrivalTime.Valid && ent.ArrivalTime.Int() > 0 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "arrival_time", + ent.ArrivalTime.String(), + "arrival_time cannot be used with pickup/drop_off time windows", + )) + } + if ent.DepartureTime.Valid && ent.DepartureTime.Int() > 0 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "departure_time", + ent.DepartureTime.String(), + "departure_time cannot be used with pickup/drop_off time windows", + )) + } + } + + // 4. Validate booking rules are only used with appropriate pickup/drop_off types + // pickup_booking_rule_id can only be used when pickup_type = 2 (continuous pickup) + // Note: Use IsPresent() to exclude empty strings, which are valid (meaning "no booking required") + if ent.PickupBookingRuleID.IsPresent() { + if !ent.PickupType.Valid || ent.PickupType.Val != 2 { + errs = append(errs, causes.NewInvalidFieldError( + "pickup_booking_rule_id", + ent.PickupBookingRuleID.Val, + fmt.Errorf("pickup_booking_rule_id requires pickup_type = 2 (on demand/continuous)"), + )) + } + } + + // drop_off_booking_rule_id can only be used when drop_off_type = 2 + // Note: Use IsPresent() to exclude empty strings, which are valid (meaning "no booking required") + if ent.DropOffBookingRuleID.IsPresent() { + if !ent.DropOffType.Valid || ent.DropOffType.Val != 2 { + errs = append(errs, causes.NewInvalidFieldError( + "drop_off_booking_rule_id", + ent.DropOffBookingRuleID.Val, + fmt.Errorf("drop_off_booking_rule_id requires drop_off_type = 2 (on demand/continuous)"), + )) + } + } + + // 5. Validate mean/safe duration factor/offset + // mean_duration_factor and mean_duration_offset work together + if ent.MeanDurationFactor.Valid && !ent.MeanDurationOffset.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("mean_duration_offset")) + } + if ent.MeanDurationOffset.Valid && !ent.MeanDurationFactor.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("mean_duration_factor")) + } + + // safe_duration_factor and safe_duration_offset work together + if ent.SafeDurationFactor.Valid && !ent.SafeDurationOffset.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("safe_duration_offset")) + } + if ent.SafeDurationOffset.Valid && !ent.SafeDurationFactor.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("safe_duration_factor")) + } + + // mean_duration_factor should be positive + if ent.MeanDurationFactor.Valid && ent.MeanDurationFactor.Val <= 0 { + errs = append(errs, causes.NewInvalidFieldError( + "mean_duration_factor", + fmt.Sprintf("%f", ent.MeanDurationFactor.Val), + fmt.Errorf("must be positive"), + )) + } + + // safe_duration_factor should be >= mean_duration_factor if both present + if ent.MeanDurationFactor.Valid && ent.SafeDurationFactor.Valid { + if ent.SafeDurationFactor.Val < ent.MeanDurationFactor.Val { + errs = append(errs, causes.NewInvalidFieldError( + "safe_duration_factor", + fmt.Sprintf("%f", ent.SafeDurationFactor.Val), + fmt.Errorf("must be greater than or equal to mean_duration_factor (%f)", ent.MeanDurationFactor.Val), + )) + } + } + + return errs +} diff --git a/gtfs/stop_time_flex_validation_test.go b/gtfs/stop_time_flex_validation_test.go new file mode 100644 index 000000000..307c254c1 --- /dev/null +++ b/gtfs/stop_time_flex_validation_test.go @@ -0,0 +1,190 @@ +package gtfs + +import ( + "testing" + + "github.com/interline-io/transitland-lib/tt" + "github.com/stretchr/testify/assert" +) + +func TestStopTime_ConditionalErrorsFlex(t *testing.T) { + tests := []struct { + name string + stopTime *StopTime + expectError bool + errorField string + }{ + { + name: "Valid: Both time windows present and consistent", + stopTime: &StopTime{ + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + }, + expectError: false, + }, + { + name: "Invalid: Only start window present", + stopTime: &StopTime{ + StartPickupDropOffWindow: tt.NewSeconds(3600), + }, + expectError: true, + errorField: "end_pickup_drop_off_window", + }, + { + name: "Invalid: Only end window present", + stopTime: &StopTime{ + EndPickupDropOffWindow: tt.NewSeconds(7200), + }, + expectError: true, + errorField: "start_pickup_drop_off_window", + }, + { + name: "Invalid: End window before start window", + stopTime: &StopTime{ + StartPickupDropOffWindow: tt.NewSeconds(7200), + EndPickupDropOffWindow: tt.NewSeconds(3600), + }, + expectError: true, + errorField: "end_pickup_drop_off_window", + }, + { + name: "Invalid: Time window with arrival_time", + stopTime: &StopTime{ + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + ArrivalTime: tt.NewSeconds(10800), + }, + expectError: true, + errorField: "arrival_time", + }, + { + name: "Invalid: Time window with departure_time", + stopTime: &StopTime{ + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + DepartureTime: tt.NewSeconds(10800), + }, + expectError: true, + errorField: "departure_time", + }, + { + name: "Valid: pickup_booking_rule_id with pickup_type=2", + stopTime: &StopTime{ + PickupBookingRuleID: tt.NewKey("rule1"), + PickupType: tt.NewInt(2), + }, + expectError: false, + }, + { + name: "Invalid: pickup_booking_rule_id without pickup_type=2", + stopTime: &StopTime{ + PickupBookingRuleID: tt.NewKey("rule1"), + PickupType: tt.NewInt(0), + }, + expectError: true, + errorField: "pickup_booking_rule_id", + }, + { + name: "Valid: empty pickup_booking_rule_id with pickup_type=2", + stopTime: &StopTime{ + PickupBookingRuleID: tt.NewKey(""), // Empty is valid (no booking required) + PickupType: tt.NewInt(2), + }, + expectError: false, + }, + { + name: "Valid: drop_off_booking_rule_id with drop_off_type=2", + stopTime: &StopTime{ + DropOffBookingRuleID: tt.NewKey("rule1"), + DropOffType: tt.NewInt(2), + }, + expectError: false, + }, + { + name: "Invalid: drop_off_booking_rule_id without drop_off_type=2", + stopTime: &StopTime{ + DropOffBookingRuleID: tt.NewKey("rule1"), + DropOffType: tt.NewInt(0), + }, + expectError: true, + errorField: "drop_off_booking_rule_id", + }, + { + name: "Valid: Both mean_duration fields present", + stopTime: &StopTime{ + MeanDurationFactor: tt.NewFloat(1.5), + MeanDurationOffset: tt.NewFloat(300), + }, + expectError: false, + }, + { + name: "Invalid: Only mean_duration_factor present", + stopTime: &StopTime{ + MeanDurationFactor: tt.NewFloat(1.5), + }, + expectError: true, + errorField: "mean_duration_offset", + }, + { + name: "Invalid: Only mean_duration_offset present", + stopTime: &StopTime{ + MeanDurationOffset: tt.NewFloat(300), + }, + expectError: true, + errorField: "mean_duration_factor", + }, + { + name: "Invalid: mean_duration_factor is negative", + stopTime: &StopTime{ + MeanDurationFactor: tt.NewFloat(-1.5), + MeanDurationOffset: tt.NewFloat(300), + }, + expectError: true, + errorField: "mean_duration_factor", + }, + { + name: "Invalid: safe_duration_factor < mean_duration_factor", + stopTime: &StopTime{ + MeanDurationFactor: tt.NewFloat(2.0), + MeanDurationOffset: tt.NewFloat(300), + SafeDurationFactor: tt.NewFloat(1.5), + SafeDurationOffset: tt.NewFloat(400), + }, + expectError: true, + errorField: "safe_duration_factor", + }, + { + name: "Valid: safe_duration_factor >= mean_duration_factor", + stopTime: &StopTime{ + MeanDurationFactor: tt.NewFloat(1.5), + MeanDurationOffset: tt.NewFloat(300), + SafeDurationFactor: tt.NewFloat(2.0), + SafeDurationOffset: tt.NewFloat(400), + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errs := tc.stopTime.conditionalErrorsFlex() + + if tc.expectError { + assert.NotEmpty(t, errs, "Expected validation error") + if len(errs) > 0 && tc.errorField != "" { + // Check that at least one error mentions the expected field + found := false + for _, err := range errs { + if err.Error() != "" { + found = true + break + } + } + assert.True(t, found, "Expected error related to field %s", tc.errorField) + } + } else { + assert.Empty(t, errs, "Expected no validation errors") + } + }) + } +} diff --git a/rules/flex_geography_id_unique.go b/rules/flex_geography_id_unique.go new file mode 100644 index 000000000..ea9f7f276 --- /dev/null +++ b/rules/flex_geography_id_unique.go @@ -0,0 +1,102 @@ +package rules + +import ( + "fmt" + + "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/tt" +) + +// FlexGeographyIDDuplicateError reports when a geography ID is duplicated across +// stops.txt, locations.geojson, or location_groups.txt. +type FlexGeographyIDDuplicateError struct { + GeographyID string + FirstFile string + SecondFile string + bc +} + +func (e *FlexGeographyIDDuplicateError) Error() string { + return fmt.Sprintf( + "geography_id '%s' is duplicated across %s and %s (IDs must be unique across stops.stop_id, locations.geojson id, and location_groups.location_group_id)", + e.GeographyID, + e.FirstFile, + e.SecondFile, + ) +} + +// FlexGeographyIDUniqueCheck validates that geography IDs are unique across +// stops.txt, locations.geojson, and location_groups.txt. +// +// Per GTFS-Flex specification, these three ID spaces are merged into a single +// namespace that can be referenced in stop_times.txt via stop_id or location_id. +type FlexGeographyIDUniqueCheck struct { + geographyIDs map[string]string // geography_id -> filename +} + +func (e *FlexGeographyIDUniqueCheck) AfterWrite(eid string, ent tt.Entity, emap *tt.EntityMap) error { + if e.geographyIDs == nil { + e.geographyIDs = map[string]string{} + } + + var geographyID string + var filename string + + switch entity := ent.(type) { + case *gtfs.Stop: + geographyID = entity.StopID.Val + filename = "stops.txt" + case *gtfs.Location: + geographyID = entity.LocationID.Val + filename = "locations.geojson" + case *gtfs.LocationGroup: + geographyID = entity.LocationGroupID.Val + filename = "location_groups.txt" + default: + return nil + } + + // Store the first occurrence + if geographyID != "" { + if _, exists := e.geographyIDs[geographyID]; !exists { + e.geographyIDs[geographyID] = filename + } + } + + return nil +} + +func (e *FlexGeographyIDUniqueCheck) Validate(ent tt.Entity) []error { + var geographyID string + var filename string + + switch entity := ent.(type) { + case *gtfs.Stop: + geographyID = entity.StopID.Val + filename = "stops.txt" + case *gtfs.Location: + geographyID = entity.LocationID.Val + filename = "locations.geojson" + case *gtfs.LocationGroup: + geographyID = entity.LocationGroupID.Val + filename = "location_groups.txt" + default: + return nil + } + + if geographyID == "" { + return nil + } + + // Check if this ID was seen in a different file + if firstFile, exists := e.geographyIDs[geographyID]; exists && firstFile != filename { + return []error{&FlexGeographyIDDuplicateError{ + GeographyID: geographyID, + FirstFile: firstFile, + SecondFile: filename, + }} + } + + return nil +} + diff --git a/rules/flex_geography_id_unique_test.go b/rules/flex_geography_id_unique_test.go new file mode 100644 index 000000000..056d46ca0 --- /dev/null +++ b/rules/flex_geography_id_unique_test.go @@ -0,0 +1,111 @@ +package rules + +import ( + "testing" + + "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/tt" +) + +func TestFlexGeographyIDUniqueCheck(t *testing.T) { + tests := []struct { + name string + entities []tt.Entity + expectError bool + errorMsg string + }{ + { + name: "no duplicates - all unique", + entities: []tt.Entity{ + >fs.Stop{StopID: tt.NewString("stop_1")}, + >fs.Stop{StopID: tt.NewString("stop_2")}, + >fs.Location{LocationID: tt.NewString("location_1")}, + >fs.LocationGroup{LocationGroupID: tt.NewString("group_1")}, + }, + expectError: false, + }, + { + name: "duplicate stop_id with location_id", + entities: []tt.Entity{ + >fs.Stop{StopID: tt.NewString("area_1")}, + >fs.Location{LocationID: tt.NewString("area_1")}, + }, + expectError: true, + errorMsg: "geography_id 'area_1' is duplicated across stops.txt and locations.geojson (IDs must be unique across stops.stop_id, locations.geojson id, and location_groups.location_group_id)", + }, + { + name: "duplicate stop_id with location_group_id", + entities: []tt.Entity{ + >fs.Stop{StopID: tt.NewString("zone_5")}, + >fs.LocationGroup{LocationGroupID: tt.NewString("zone_5")}, + }, + expectError: true, + errorMsg: "geography_id 'zone_5' is duplicated across stops.txt and location_groups.txt (IDs must be unique across stops.stop_id, locations.geojson id, and location_groups.location_group_id)", + }, + { + name: "duplicate location_id with location_group_id", + entities: []tt.Entity{ + >fs.Location{LocationID: tt.NewString("flex_area")}, + >fs.LocationGroup{LocationGroupID: tt.NewString("flex_area")}, + }, + expectError: true, + errorMsg: "geography_id 'flex_area' is duplicated across locations.geojson and location_groups.txt (IDs must be unique across stops.stop_id, locations.geojson id, and location_groups.location_group_id)", + }, + { + name: "multiple stops with same ID within stops.txt (should pass this check)", + entities: []tt.Entity{ + >fs.Stop{StopID: tt.NewString("stop_1")}, + >fs.Stop{StopID: tt.NewString("stop_1")}, + }, + expectError: false, // This check doesn't validate duplicates within the same file + }, + { + name: "empty IDs (should not trigger error)", + entities: []tt.Entity{ + >fs.Stop{StopID: tt.NewString("")}, + >fs.Location{LocationID: tt.NewString("")}, + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + check := &FlexGeographyIDUniqueCheck{} + emap := tt.NewEntityMap() + + // First pass: AfterWrite to collect IDs + for _, ent := range tc.entities { + err := check.AfterWrite("", ent, emap) + if err != nil { + t.Fatalf("AfterWrite failed: %v", err) + } + } + + // Second pass: Validate to detect duplicates + var foundError bool + var errorMessage string + for _, ent := range tc.entities { + errs := check.Validate(ent) + if len(errs) > 0 { + foundError = true + errorMessage = errs[0].Error() + break + } + } + + if tc.expectError && !foundError { + t.Errorf("Expected error but got none") + } + if !tc.expectError && foundError { + t.Errorf("Expected no error but got: %s", errorMessage) + } + if tc.expectError && foundError && tc.errorMsg != "" { + if errorMessage != tc.errorMsg { + t.Errorf("Expected error message:\n %q\nGot:\n %q", tc.errorMsg, errorMessage) + } + } + }) + } +} + diff --git a/rules/flex_location_geometry.go b/rules/flex_location_geometry.go new file mode 100644 index 000000000..248343b94 --- /dev/null +++ b/rules/flex_location_geometry.go @@ -0,0 +1,64 @@ +package rules + +import ( + "fmt" + + "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/tt" +) + +// FlexLocationGeometryError reports when a location has invalid geometry. +type FlexLocationGeometryError struct { + LocationID string + ErrorMessage string + bc +} + +func (e *FlexLocationGeometryError) Error() string { + return fmt.Sprintf( + "location '%s' has invalid geometry: %s", + e.LocationID, + e.ErrorMessage, + ) +} + +// FlexLocationGeometryCheck validates that locations have valid Polygon or MultiPolygon geometries. +type FlexLocationGeometryCheck struct{} + +func (e *FlexLocationGeometryCheck) Validate(ent tt.Entity) []error { + loc, ok := ent.(*gtfs.Location) + if !ok { + return nil + } + + var errs []error + + // Geometry must be present (already checked in ConditionalErrors) + if !loc.Geometry.Valid { + return nil + } + + // Validate that geometry is Polygon or MultiPolygon + geom := loc.Geometry.Val + switch geom.(type) { + case nil: + errs = append(errs, &FlexLocationGeometryError{ + LocationID: loc.LocationID.Val, + ErrorMessage: "geometry is nil", + }) + default: + // Additional geometry validation could go here + // For example: check for self-intersections, minimum area, etc. + // Check that polygon has at least 3 points + coords := loc.Geometry.FlatCoords() + if len(coords) < 6 { // At least 3 points (x,y) = 6 coordinates for a closed ring + errs = append(errs, &FlexLocationGeometryError{ + LocationID: loc.LocationID.Val, + ErrorMessage: fmt.Sprintf("polygon has insufficient coordinates: %d (need at least 6 for closed triangle)", len(coords)), + }) + } + } + + return errs +} + diff --git a/rules/flex_location_group_empty.go b/rules/flex_location_group_empty.go new file mode 100644 index 000000000..6b4715071 --- /dev/null +++ b/rules/flex_location_group_empty.go @@ -0,0 +1,57 @@ +package rules + +import ( + "fmt" + + "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/tt" +) + +// FlexLocationGroupEmptyError reports when a location_group has no associated stops. +type FlexLocationGroupEmptyError struct { + LocationGroupID string + bc +} + +func (e *FlexLocationGroupEmptyError) Error() string { + return fmt.Sprintf( + "location_group '%s' has no associated stops in location_group_stops.txt", + e.LocationGroupID, + ) +} + +// FlexLocationGroupEmptyCheck validates that all location_groups have at least one stop. +type FlexLocationGroupEmptyCheck struct { + locationGroupStops map[string]int // location_group_id -> count of stops +} + +func (e *FlexLocationGroupEmptyCheck) AfterWrite(eid string, ent tt.Entity, emap *tt.EntityMap) error { + if e.locationGroupStops == nil { + e.locationGroupStops = map[string]int{} + } + + // Count stops for each location_group + if lgs, ok := ent.(*gtfs.LocationGroupStop); ok { + e.locationGroupStops[lgs.LocationGroupID.Val]++ + } + + return nil +} + +func (e *FlexLocationGroupEmptyCheck) Validate(ent tt.Entity) []error { + lg, ok := ent.(*gtfs.LocationGroup) + if !ok { + return nil + } + + // Check if this location_group has any stops + count := e.locationGroupStops[lg.LocationGroupID.Val] + if count == 0 { + return []error{&FlexLocationGroupEmptyError{ + LocationGroupID: lg.LocationGroupID.Val, + }} + } + + return nil +} + diff --git a/rules/flex_stop_location_type.go b/rules/flex_stop_location_type.go new file mode 100644 index 000000000..79d4f81ba --- /dev/null +++ b/rules/flex_stop_location_type.go @@ -0,0 +1,88 @@ +package rules + +import ( + "fmt" + + "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/tt" +) + +// FlexStopLocationTypeError reports when a stop referenced in a flex service has invalid location_type. +type FlexStopLocationTypeError struct { + StopID string + LocationType int + bc +} + +func (e *FlexStopLocationTypeError) Error() string { + return fmt.Sprintf( + "stop '%s' referenced in flex service has location_type %d, but flex services can only use stops with location_type 0 (stop/platform)", + e.StopID, + e.LocationType, + ) +} + +// FlexStopLocationTypeCheck validates that stops referenced in flex stop_times have appropriate location_type. +// According to GTFS-Flex, stops used in continuous stopping or location_id/location_group_id references +// must be location_type=0 (stop/platform). +type FlexStopLocationTypeCheck struct { + locationTypes map[string]int + flexStops map[string]bool // stops used in flex services +} + +func (e *FlexStopLocationTypeCheck) AfterWrite(eid string, ent tt.Entity, emap *tt.EntityMap) error { + if e.locationTypes == nil { + e.locationTypes = map[string]int{} + } + if e.flexStops == nil { + e.flexStops = map[string]bool{} + } + + // Track location_type for all stops + if stop, ok := ent.(*gtfs.Stop); ok { + e.locationTypes[eid] = stop.LocationType.Int() + } + + // Track stops used in flex services + if st, ok := ent.(*gtfs.StopTime); ok { + // Check if this stop_time indicates a flex service + isFlex := (st.ContinuousPickup.Valid && st.ContinuousPickup.Val == 2) || + (st.ContinuousDropOff.Valid && st.ContinuousDropOff.Val == 2) || + (st.PickupType.Valid && st.PickupType.Val == 2) || + (st.DropOffType.Valid && st.DropOffType.Val == 2) || + st.StartPickupDropOffWindow.Valid || + st.EndPickupDropOffWindow.Valid || + st.PickupBookingRuleID.Valid || + st.DropOffBookingRuleID.Valid + + if isFlex { + e.flexStops[st.StopID.Val] = true + } + } + + return nil +} + +func (e *FlexStopLocationTypeCheck) Validate(ent tt.Entity) []error { + // Run validation after all entities are processed + stop, ok := ent.(*gtfs.Stop) + if !ok { + return nil + } + + // Check if this stop is used in flex services + if !e.flexStops[stop.StopID.Val] { + return nil + } + + // Flex stops must be location_type = 0 + locationType := e.locationTypes[stop.StopID.Val] + if locationType != 0 { + return []error{&FlexStopLocationTypeError{ + StopID: stop.StopID.Val, + LocationType: locationType, + }} + } + + return nil +} diff --git a/rules/flex_validation_test.go b/rules/flex_validation_test.go new file mode 100644 index 000000000..f1823b053 --- /dev/null +++ b/rules/flex_validation_test.go @@ -0,0 +1,215 @@ +package rules + +import ( + "fmt" + "testing" + + "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/tt" + "github.com/stretchr/testify/assert" +) + +func TestFlexStopLocationTypeCheck(t *testing.T) { + emap := tt.NewEntityMap() + + tests := []struct { + name string + stop *gtfs.Stop + stopTime *gtfs.StopTime + expectError bool + }{ + { + name: "Valid: location_type=0 with flex service", + stop: >fs.Stop{ + StopID: tt.NewString("stop1"), + LocationType: tt.NewInt(0), + }, + stopTime: >fs.StopTime{ + StopID: tt.NewString("stop1"), + PickupType: tt.NewInt(2), // flex service + }, + expectError: false, + }, + { + name: "Invalid: location_type=1 (station) with flex service", + stop: >fs.Stop{ + StopID: tt.NewString("stop2"), + LocationType: tt.NewInt(1), + }, + stopTime: >fs.StopTime{ + StopID: tt.NewString("stop2"), + PickupType: tt.NewInt(2), + }, + expectError: true, + }, + { + name: "Valid: location_type=1 without flex service", + stop: >fs.Stop{ + StopID: tt.NewString("stop3"), + LocationType: tt.NewInt(1), + }, + stopTime: >fs.StopTime{ + StopID: tt.NewString("stop3"), + PickupType: tt.NewInt(0), // regular service + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + check := &FlexStopLocationTypeCheck{} + + // Important: Process entities in order - stops first, then stop_times + // This mimics how the copier processes entities + + // 1. Add the stop + tc.stop.ID = 1 + check.AfterWrite(tc.stop.StopID.Val, tc.stop, emap) + + // 2. Add the stop_time (this marks the stop as flex if applicable) + check.AfterWrite(tc.stopTime.StopID.Val, tc.stopTime, emap) + + // 3. Validate the stop (this checks if flex stops have correct location_type) + errs := check.Validate(tc.stop) + + if tc.expectError { + assert.NotEmpty(t, errs, "Expected validation error") + if len(errs) > 0 { + _, ok := errs[0].(*FlexStopLocationTypeError) + assert.True(t, ok, "Expected FlexStopLocationTypeError") + } + } else { + assert.Empty(t, errs, "Expected no validation errors") + } + }) + } +} + +func TestFlexLocationGroupEmptyCheck(t *testing.T) { + emap := tt.NewEntityMap() + + tests := []struct { + name string + group *gtfs.LocationGroup + stops []*gtfs.LocationGroupStop + expectError bool + }{ + { + name: "Valid: location_group with stops", + group: >fs.LocationGroup{ + LocationGroupID: tt.NewString("group1"), + }, + stops: []*gtfs.LocationGroupStop{ + {LocationGroupID: tt.NewKey("group1"), StopID: tt.NewKey("stop1")}, + {LocationGroupID: tt.NewKey("group1"), StopID: tt.NewKey("stop2")}, + }, + expectError: false, + }, + { + name: "Invalid: location_group with no stops", + group: >fs.LocationGroup{ + LocationGroupID: tt.NewString("group2"), + }, + stops: []*gtfs.LocationGroupStop{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + check := &FlexLocationGroupEmptyCheck{} + + // Add the location_group + tc.group.ID = 1 + check.AfterWrite("1", tc.group, emap) + + // Add the location_group_stops + for i, lgs := range tc.stops { + lgs.ID = i + 2 + check.AfterWrite(fmt.Sprintf("%d", i+2), lgs, emap) + } + + // Validate + errs := check.Validate(tc.group) + + if tc.expectError { + assert.NotEmpty(t, errs, "Expected validation error") + if len(errs) > 0 { + _, ok := errs[0].(*FlexLocationGroupEmptyError) + assert.True(t, ok, "Expected FlexLocationGroupEmptyError") + } + } else { + assert.Empty(t, errs, "Expected no validation errors") + } + }) + } +} + +func TestFlexZoneIDConditionalCheck(t *testing.T) { + emap := tt.NewEntityMap() + + tests := []struct { + name string + location *gtfs.Location + fareRule *gtfs.FareRule + expectError bool + }{ + { + name: "Valid: zone_id present with fare_rules", + location: >fs.Location{ + LocationID: tt.NewString("loc1"), + ZoneID: tt.NewString("zone1"), + }, + fareRule: >fs.FareRule{FareID: tt.NewString("fare1")}, + expectError: false, + }, + { + name: "Invalid: zone_id missing with fare_rules", + location: >fs.Location{ + LocationID: tt.NewString("loc2"), + ZoneID: tt.String{}, + }, + fareRule: >fs.FareRule{FareID: tt.NewString("fare1")}, + expectError: true, + }, + { + name: "Valid: zone_id missing without fare_rules", + location: >fs.Location{ + LocationID: tt.NewString("loc3"), + ZoneID: tt.String{}, + }, + fareRule: nil, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + check := &FlexZoneIDConditionalCheck{} + + // Add fare_rule if present + if tc.fareRule != nil { + tc.fareRule.ID = 1 + check.AfterWrite("1", tc.fareRule, emap) + } + + // Add the location + tc.location.ID = 2 + check.AfterWrite("2", tc.location, emap) + + // Validate + errs := check.Validate(tc.location) + + if tc.expectError { + assert.NotEmpty(t, errs, "Expected validation error") + if len(errs) > 0 { + _, ok := errs[0].(*FlexZoneIDRequiredError) + assert.True(t, ok, "Expected FlexZoneIDRequiredError") + } + } else { + assert.Empty(t, errs, "Expected no validation errors") + } + }) + } +} diff --git a/rules/flex_zone_id_conditional.go b/rules/flex_zone_id_conditional.go new file mode 100644 index 000000000..5c615f398 --- /dev/null +++ b/rules/flex_zone_id_conditional.go @@ -0,0 +1,57 @@ +package rules + +import ( + "fmt" + + "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/tt" +) + +// FlexZoneIDRequiredError reports when zone_id is missing in locations.geojson but fare_rules.txt exists. +type FlexZoneIDRequiredError struct { + LocationID string + bc +} + +func (e *FlexZoneIDRequiredError) Error() string { + return fmt.Sprintf( + "location '%s' requires zone_id when fare_rules.txt is defined", + e.LocationID, + ) +} + +// FlexZoneIDConditionalCheck validates that locations have zone_id when fare_rules.txt exists. +type FlexZoneIDConditionalCheck struct { + hasFareRules bool + checkedFiles bool +} + +func (e *FlexZoneIDConditionalCheck) AfterWrite(eid string, ent tt.Entity, emap *tt.EntityMap) error { + // Check if fare_rules.txt has any entries + if _, ok := ent.(*gtfs.FareRule); ok { + e.hasFareRules = true + } + e.checkedFiles = true + return nil +} + +func (e *FlexZoneIDConditionalCheck) Validate(ent tt.Entity) []error { + if !e.checkedFiles { + return nil + } + + loc, ok := ent.(*gtfs.Location) + if !ok { + return nil + } + + // If fare_rules.txt exists, zone_id is required in locations + if e.hasFareRules && !loc.ZoneID.Valid { + return []error{&FlexZoneIDRequiredError{ + LocationID: loc.LocationID.Val, + }} + } + + return nil +} + diff --git a/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql b/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql new file mode 100644 index 000000000..a47169a1b --- /dev/null +++ b/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql @@ -0,0 +1,197 @@ +BEGIN; + +-- GTFS-Flex: location_groups.txt +CREATE TABLE public.gtfs_location_groups ( + id bigserial primary key not null, + feed_version_id bigint REFERENCES feed_versions(id) not null, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + + location_group_id text NOT NULL, + location_group_name text +); +CREATE INDEX ON gtfs_location_groups(feed_version_id); +CREATE UNIQUE INDEX ON gtfs_location_groups(feed_version_id, location_group_id); + + +-- GTFS-Flex: location_group_stops.txt +CREATE TABLE public.gtfs_location_group_stops ( + id bigserial primary key not null, + feed_version_id bigint REFERENCES feed_versions(id) not null, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + + location_group_id bigint not null REFERENCES gtfs_location_groups(id), + stop_id bigint not null REFERENCES gtfs_stops(id) +); +CREATE INDEX ON gtfs_location_group_stops(feed_version_id); +CREATE INDEX ON gtfs_location_group_stops(location_group_id); +CREATE INDEX ON gtfs_location_group_stops(stop_id); +CREATE UNIQUE INDEX ON gtfs_location_group_stops(location_group_id, stop_id); + + +-- GTFS-Flex: booking_rules.txt +CREATE TABLE public.gtfs_booking_rules ( + id bigserial primary key not null, + feed_version_id bigint REFERENCES feed_versions(id) not null, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + + booking_rule_id text NOT NULL, + booking_type integer NOT NULL, + prior_notice_duration_min integer, + prior_notice_duration_max integer, + prior_notice_last_day integer, + prior_notice_last_time integer, + prior_notice_start_day integer, + prior_notice_start_time integer, + prior_notice_service_id text, + message text, + pickup_message text, + drop_off_message text, + phone_number text, + info_url text, + booking_url text +); +CREATE INDEX ON gtfs_booking_rules(feed_version_id); +CREATE UNIQUE INDEX ON gtfs_booking_rules(feed_version_id, booking_rule_id); + + +-- GTFS-Flex: locations.geojson +CREATE TABLE public.gtfs_locations ( + id bigserial primary key not null, + feed_version_id bigint REFERENCES feed_versions(id) not null, + created_at timestamp without time zone DEFAULT now() NOT NULL, + updated_at timestamp without time zone DEFAULT now() NOT NULL, + + location_id text NOT NULL, + stop_name text, + stop_desc text, + zone_id text, + stop_url text, + -- Geometry can be Polygon or MultiPolygon + geometry public.geography(Geometry,4326) +); +CREATE INDEX ON gtfs_locations(feed_version_id); +CREATE UNIQUE INDEX ON gtfs_locations(feed_version_id, location_id); +CREATE INDEX ON gtfs_locations USING GIST(geometry); + + +-- GTFS-Flex: Add new fields to stop_times +-- Note: stop_times is partitioned, so we need to add columns to each partition + +-- Add columns to parent table +ALTER TABLE public.gtfs_stop_times + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +-- Add columns to each partition +ALTER TABLE public.gtfs_stop_times_0 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_1 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_2 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_3 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_4 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_5 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_6 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_7 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_8 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +ALTER TABLE public.gtfs_stop_times_9 + ADD COLUMN start_pickup_drop_off_window integer, + ADD COLUMN end_pickup_drop_off_window integer, + ADD COLUMN pickup_booking_rule_id bigint, + ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN mean_duration_factor double precision, + ADD COLUMN mean_duration_offset double precision, + ADD COLUMN safe_duration_factor double precision, + ADD COLUMN safe_duration_offset double precision; + +COMMIT; + + diff --git a/schema/sqlite/sqlite.sql b/schema/sqlite/sqlite.sql index 8411d4c64..97e1b45b5 100644 --- a/schema/sqlite/sqlite.sql +++ b/schema/sqlite/sqlite.sql @@ -399,9 +399,20 @@ CREATE TABLE IF NOT EXISTS "gtfs_stop_times" ( "continuous_drop_off" integer, "interpolated" integer, "feed_version_id" integer NOT NULL, + -- GTFS-Flex fields + "start_pickup_drop_off_window" integer, + "end_pickup_drop_off_window" integer, + "pickup_booking_rule_id" integer, + "drop_off_booking_rule_id" integer, + "mean_duration_factor" real, + "mean_duration_offset" real, + "safe_duration_factor" real, + "safe_duration_offset" real, foreign key(feed_version_id) REFERENCES feed_versions(id), foreign key(trip_id) references gtfs_trips(id), - foreign key(stop_id) references gtfs_stops(id) + foreign key(stop_id) references gtfs_stops(id), + foreign key(pickup_booking_rule_id) references gtfs_booking_rules(id), + foreign key(drop_off_booking_rule_id) references gtfs_booking_rules(id) ); CREATE INDEX idx_stop_times_trip_id ON "gtfs_stop_times"(trip_id); CREATE INDEX idx_gtfs_stop_times_stop_id ON "gtfs_stop_times"(stop_id); @@ -946,4 +957,77 @@ CREATE TABLE tl_materialized_active_agencies ( CREATE UNIQUE INDEX tl_materialized_active_agencies_id_idx ON tl_materialized_active_agencies(id); CREATE INDEX tl_materialized_active_agencies_agency_id_idx ON tl_materialized_active_agencies(agency_id); CREATE INDEX tl_materialized_active_agencies_feed_version_id_idx ON tl_materialized_active_agencies(feed_version_id); -CREATE INDEX tl_materialized_active_agencies_onestop_id_idx ON tl_materialized_active_agencies(onestop_id); \ No newline at end of file +CREATE INDEX tl_materialized_active_agencies_onestop_id_idx ON tl_materialized_active_agencies(onestop_id); + +-- GTFS-Flex: location_groups.txt +CREATE TABLE IF NOT EXISTS "gtfs_location_groups" ( + "id" integer primary key autoincrement, + "feed_version_id" integer NOT NULL, + "created_at" datetime DEFAULT CURRENT_TIMESTAMP, + "updated_at" datetime DEFAULT CURRENT_TIMESTAMP, + "location_group_id" varchar(255) NOT NULL, + "location_group_name" varchar(255), + foreign key(feed_version_id) REFERENCES feed_versions(id) +); +CREATE INDEX idx_gtfs_location_groups_feed_version_id ON "gtfs_location_groups"(feed_version_id); +CREATE UNIQUE INDEX idx_gtfs_location_groups_fv_id ON "gtfs_location_groups"(feed_version_id, location_group_id); + +-- GTFS-Flex: location_group_stops.txt +CREATE TABLE IF NOT EXISTS "gtfs_location_group_stops" ( + "id" integer primary key autoincrement, + "feed_version_id" integer NOT NULL, + "created_at" datetime DEFAULT CURRENT_TIMESTAMP, + "updated_at" datetime DEFAULT CURRENT_TIMESTAMP, + "location_group_id" integer NOT NULL, + "stop_id" integer NOT NULL, + foreign key(feed_version_id) REFERENCES feed_versions(id), + foreign key(location_group_id) references gtfs_location_groups(id), + foreign key(stop_id) references gtfs_stops(id) +); +CREATE INDEX idx_gtfs_location_group_stops_feed_version_id ON "gtfs_location_group_stops"(feed_version_id); +CREATE INDEX idx_gtfs_location_group_stops_location_group_id ON "gtfs_location_group_stops"(location_group_id); +CREATE INDEX idx_gtfs_location_group_stops_stop_id ON "gtfs_location_group_stops"(stop_id); +CREATE UNIQUE INDEX idx_gtfs_location_group_stops_lg_stop ON "gtfs_location_group_stops"(location_group_id, stop_id); + +-- GTFS-Flex: booking_rules.txt +CREATE TABLE IF NOT EXISTS "gtfs_booking_rules" ( + "id" integer primary key autoincrement, + "feed_version_id" integer NOT NULL, + "created_at" datetime DEFAULT CURRENT_TIMESTAMP, + "updated_at" datetime DEFAULT CURRENT_TIMESTAMP, + "booking_rule_id" varchar(255) NOT NULL, + "booking_type" integer NOT NULL, + "prior_notice_duration_min" integer, + "prior_notice_duration_max" integer, + "prior_notice_last_day" integer, + "prior_notice_last_time" integer, + "prior_notice_start_day" integer, + "prior_notice_start_time" integer, + "prior_notice_service_id" varchar(255), + "message" text, + "pickup_message" text, + "drop_off_message" text, + "phone_number" varchar(255), + "info_url" text, + "booking_url" text, + foreign key(feed_version_id) REFERENCES feed_versions(id) +); +CREATE INDEX idx_gtfs_booking_rules_feed_version_id ON "gtfs_booking_rules"(feed_version_id); +CREATE UNIQUE INDEX idx_gtfs_booking_rules_fv_id ON "gtfs_booking_rules"(feed_version_id, booking_rule_id); + +-- GTFS-Flex: locations.geojson +CREATE TABLE IF NOT EXISTS "gtfs_locations" ( + "id" integer primary key autoincrement, + "feed_version_id" integer NOT NULL, + "created_at" datetime DEFAULT CURRENT_TIMESTAMP, + "updated_at" datetime DEFAULT CURRENT_TIMESTAMP, + "location_id" varchar(255) NOT NULL, + "stop_name" varchar(255), + "stop_desc" text, + "zone_id" varchar(255), + "stop_url" text, + "geometry" BLOB, + foreign key(feed_version_id) REFERENCES feed_versions(id) +); +CREATE INDEX idx_gtfs_locations_feed_version_id ON "gtfs_locations"(feed_version_id); +CREATE UNIQUE INDEX idx_gtfs_locations_fv_id ON "gtfs_locations"(feed_version_id, location_id); \ No newline at end of file diff --git a/tlcsv/geojson.go b/tlcsv/geojson.go new file mode 100644 index 000000000..994f5d63e --- /dev/null +++ b/tlcsv/geojson.go @@ -0,0 +1,141 @@ +package tlcsv + +import ( + "encoding/json" + "io" + + "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/tt" + geom "github.com/twpayne/go-geom" + "github.com/twpayne/go-geom/encoding/geojson" +) + +// GeoJSONFeatureParser is a callback function for parsing a GeoJSON feature +// into a GTFS entity. It receives the feature and should return the parsed +// entity and whether it was successfully parsed. +type GeoJSONFeatureParser[T any] func(*geojson.Feature) (T, bool) + +// readGeoJSON reads and parses a GeoJSON FeatureCollection file using a +// provided parser function. This provides a generic way to read any GeoJSON +// file format into GTFS entities. +func readGeoJSON[T any](reader *Reader, filename string, parser GeoJSONFeatureParser[T]) ([]T, error) { + var entities []T + + err := reader.Adapter.OpenFile(filename, func(in io.Reader) { + var fc geojson.FeatureCollection + if err := json.NewDecoder(in).Decode(&fc); err != nil { + return + } + + for _, feature := range fc.Features { + if entity, ok := parser(feature); ok { + entities = append(entities, entity) + } + } + }) + + if err != nil { + return nil, err + } + + return entities, nil +} + +// parseLocationFeature parses a GeoJSON feature into a gtfs.Location. +// This is used for locations.geojson (GTFS-Flex extension). +func parseLocationFeature(feature *geojson.Feature) (gtfs.Location, bool) { + loc := gtfs.Location{} + + // The ID is at the feature level, not in properties + if feature.ID != "" { + loc.LocationID = tt.NewString(feature.ID) + } + + // Parse properties + if feature.Properties != nil { + if v, ok := feature.Properties["stop_name"].(string); ok { + loc.StopName = tt.NewString(v) + } + if v, ok := feature.Properties["stop_desc"].(string); ok { + loc.StopDesc = tt.NewString(v) + } + if v, ok := feature.Properties["zone_id"].(string); ok { + loc.ZoneID = tt.NewString(v) + } + if v, ok := feature.Properties["stop_url"].(string); ok { + loc.StopURL = tt.NewUrl(v) + } + } + + // Parse geometry - must be Polygon or MultiPolygon for locations + if feature.Geometry != nil { + switch g := feature.Geometry.(type) { + case *geom.Polygon: + g.SetSRID(4326) + loc.Geometry = tt.NewGeometry(g) + case *geom.MultiPolygon: + g.SetSRID(4326) + loc.Geometry = tt.NewGeometry(g) + default: + // Invalid geometry type for location - skip + return loc, false + } + } + + return loc, true +} + +// readLocationsGeoJSON reads and parses locations.geojson from the adapter. +// This is a GTFS-Flex extension that defines zones using GeoJSON Polygon +// or MultiPolygon geometries where riders can request pickups or drop-offs. +func (reader *Reader) readLocationsGeoJSON(filename string) ([]gtfs.Location, error) { + return readGeoJSON(reader, filename, parseLocationFeature) +} + +// Example: To add support for level.geojson in the future, create a parser function: +// +// func parseLevelFeature(feature *geojson.Feature) (gtfs.Level, bool) { +// level := gtfs.Level{} +// if feature.ID != "" { +// level.LevelID = tt.NewString(feature.ID) +// } +// if feature.Properties != nil { +// if v, ok := feature.Properties["level_name"].(string); ok { +// level.LevelName = tt.NewString(v) +// } +// if v, ok := feature.Properties["level_index"].(float64); ok { +// level.LevelIndex = tt.NewFloat(v) +// } +// } +// // Parse geometry (Polygon or MultiPolygon) +// if feature.Geometry != nil { +// switch g := feature.Geometry.(type) { +// case *geom.Polygon, *geom.MultiPolygon: +// g.SetSRID(4326) +// level.Geometry = tt.NewGeometry(g) +// default: +// return level, false +// } +// } +// return level, true +// } +// +// Then in reader.go, add: +// +// func (reader *Reader) Levels() chan gtfs.Level { +// out := make(chan gtfs.Level, bufferSize) +// go func() { +// defer close(out) +// // Try GeoJSON first +// levels, err := readGeoJSON(reader, "levels.geojson", parseLevelFeature) +// if err == nil { +// for _, level := range levels { +// out <- level +// } +// return +// } +// // Fall back to CSV +// ReadEntities[gtfs.Level](reader, "levels.txt") +// }() +// return out +// } diff --git a/tlcsv/geojson_test.go b/tlcsv/geojson_test.go new file mode 100644 index 000000000..fbc0212d6 --- /dev/null +++ b/tlcsv/geojson_test.go @@ -0,0 +1,208 @@ +package tlcsv + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReader_Locations_GeoJSON(t *testing.T) { + // Create a temporary GTFS feed with locations.geojson + tmpDir := t.TempDir() + + // Create minimal required GTFS files + agencyCSV := `agency_id,agency_name,agency_url,agency_timezone +1,Demo Transit,http://example.com,America/Los_Angeles` + + stopsCSV := `stop_id,stop_name,stop_lat,stop_lon +stop1,Stop 1,37.7749,-122.4194` + + routesCSV := `route_id,route_short_name,route_long_name,route_type,agency_id +route1,1,Main Street,3,1` + + // Create locations.geojson with GTFS-Flex zones + geojsonData := `{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "zone_downtown", + "properties": { + "stop_name": "Downtown Flex Zone", + "stop_desc": "On-demand service area in downtown", + "zone_id": "zone1", + "stop_url": "https://example.com/flex/downtown" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-122.4194, 37.7749], + [-122.4094, 37.7749], + [-122.4094, 37.7649], + [-122.4194, 37.7649], + [-122.4194, 37.7749] + ]] + } + }, + { + "type": "Feature", + "id": "zone_midtown", + "properties": { + "stop_name": "Midtown Flex Zone" + }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [[ + [-122.5, 37.8], + [-122.4, 37.8], + [-122.4, 37.7], + [-122.5, 37.7], + [-122.5, 37.8] + ]], + [[ + [-122.3, 37.9], + [-122.2, 37.9], + [-122.2, 37.8], + [-122.3, 37.8], + [-122.3, 37.9] + ]] + ] + } + } + ] +}` + + // Write files + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "agency.txt"), []byte(agencyCSV), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "stops.txt"), []byte(stopsCSV), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "routes.txt"), []byte(routesCSV), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "locations.geojson"), []byte(geojsonData), 0644)) + + // Create reader + reader, err := NewReader(tmpDir) + require.NoError(t, err) + require.NoError(t, reader.Open()) + defer reader.Close() + + // Read locations from GeoJSON + locations := reader.Locations() + + locMap := make(map[string]bool) + count := 0 + for loc := range locations { + count++ + locMap[loc.LocationID.Val] = true + + if loc.LocationID.Val == "zone_downtown" { + assert.Equal(t, "Downtown Flex Zone", loc.StopName.Val) + assert.Equal(t, "On-demand service area in downtown", loc.StopDesc.Val) + assert.Equal(t, "zone1", loc.ZoneID.Val) + assert.Equal(t, "https://example.com/flex/downtown", loc.StopURL.Val) + assert.True(t, loc.Geometry.Valid, "Geometry should be valid") + } + + if loc.LocationID.Val == "zone_midtown" { + assert.Equal(t, "Midtown Flex Zone", loc.StopName.Val) + assert.True(t, loc.Geometry.Valid, "MultiPolygon geometry should be valid") + } + } + + assert.Equal(t, 2, count) + assert.True(t, locMap["zone_downtown"]) + assert.True(t, locMap["zone_midtown"]) +} + +func TestReader_Locations_NoGeoJSON(t *testing.T) { + // Create a feed without locations.geojson + tmpDir := t.TempDir() + + agencyCSV := `agency_id,agency_name,agency_url,agency_timezone +1,Demo Transit,http://example.com,America/Los_Angeles` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "agency.txt"), []byte(agencyCSV), 0644)) + + reader, err := NewReader(tmpDir) + require.NoError(t, err) + require.NoError(t, reader.Open()) + defer reader.Close() + + // Should return empty channel without error + locations := reader.Locations() + + count := 0 + for range locations { + count++ + } + + assert.Equal(t, 0, count, "Should return no locations when file doesn't exist") +} + +func TestReader_Locations_AllFormats(t *testing.T) { + // Verify reader can handle regular stops AND GeoJSON locations together + tmpDir := t.TempDir() + + agencyCSV := `agency_id,agency_name,agency_url,agency_timezone +1,Demo Transit,http://example.com,America/Los_Angeles` + + stopsCSV := `stop_id,stop_name,stop_lat,stop_lon +stop1,Regular Stop,37.7749,-122.4194` + + routesCSV := `route_id,route_short_name,route_long_name,route_type,agency_id +route1,1,Route 1,3,1` + + geojsonData := `{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "id": "flex1", + "properties": { + "stop_name": "Flex Zone" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-122.4, 37.7], + [-122.3, 37.7], + [-122.3, 37.6], + [-122.4, 37.6], + [-122.4, 37.7] + ]] + } + } + ] +}` + + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "agency.txt"), []byte(agencyCSV), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "stops.txt"), []byte(stopsCSV), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "routes.txt"), []byte(routesCSV), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "locations.geojson"), []byte(geojsonData), 0644)) + + reader, err := NewReader(tmpDir) + require.NoError(t, err) + require.NoError(t, reader.Open()) + defer reader.Close() + + // Can read regular stops + stops := reader.Stops() + stopCount := 0 + for range stops { + stopCount++ + } + assert.Equal(t, 1, stopCount) + + // Can read flex locations + locations := reader.Locations() + locCount := 0 + for loc := range locations { + locCount++ + assert.Equal(t, "flex1", loc.LocationID.Val) + } + assert.Equal(t, 1, locCount) +} + + diff --git a/tlcsv/reader.go b/tlcsv/reader.go index 9078b5c93..ba26d3a09 100644 --- a/tlcsv/reader.go +++ b/tlcsv/reader.go @@ -426,6 +426,38 @@ func (reader *Reader) RouteNetworks() (out chan gtfs.RouteNetwork) { return ReadEntities[gtfs.RouteNetwork](reader, getFilename(>fs.RouteNetwork{})) } +func (reader *Reader) LocationGroups() (out chan gtfs.LocationGroup) { + return ReadEntities[gtfs.LocationGroup](reader, getFilename(>fs.LocationGroup{})) +} + +func (reader *Reader) LocationGroupStops() (out chan gtfs.LocationGroupStop) { + return ReadEntities[gtfs.LocationGroupStop](reader, getFilename(>fs.LocationGroupStop{})) +} + +func (reader *Reader) BookingRules() (out chan gtfs.BookingRule) { + return ReadEntities[gtfs.BookingRule](reader, getFilename(>fs.BookingRule{})) +} + +func (reader *Reader) Locations() (out chan gtfs.Location) { + // GTFS-Flex: locations.geojson uses GeoJSON format, not CSV + // Try to read locations.geojson first + out = make(chan gtfs.Location, bufferSize) + go func() { + defer close(out) + + locs, err := reader.readLocationsGeoJSON("locations.geojson") + if err != nil { + // File doesn't exist or error reading - just return empty + return + } + + for _, loc := range locs { + out <- loc + } + }() + return out +} + func ReadEntities[T any](reader *Reader, efn string) chan T { eout := make(chan T, bufferSize) go func(fn string, c chan T) { diff --git a/tldb/reader.go b/tldb/reader.go index bd99ebcec..3a2228621 100644 --- a/tldb/reader.go +++ b/tldb/reader.go @@ -313,6 +313,22 @@ func (reader *Reader) RouteNetworks() (out chan gtfs.RouteNetwork) { return ReadEntities[gtfs.RouteNetwork](reader, GetTableName(>fs.RouteNetwork{})) } +func (reader *Reader) LocationGroups() (out chan gtfs.LocationGroup) { + return ReadEntities[gtfs.LocationGroup](reader, GetTableName(>fs.LocationGroup{})) +} + +func (reader *Reader) LocationGroupStops() (out chan gtfs.LocationGroupStop) { + return ReadEntities[gtfs.LocationGroupStop](reader, GetTableName(>fs.LocationGroupStop{})) +} + +func (reader *Reader) BookingRules() (out chan gtfs.BookingRule) { + return ReadEntities[gtfs.BookingRule](reader, GetTableName(>fs.BookingRule{})) +} + +func (reader *Reader) Locations() (out chan gtfs.Location) { + return ReadEntities[gtfs.Location](reader, GetTableName(>fs.Location{})) +} + func ReadEntities[T tt.EntityWithID](reader *Reader, table string) chan T { ctx := context.TODO() out := make(chan T, bufferSize) From 26d8c05e8a4cdc055c40560d65f31db0fe695ab6 Mon Sep 17 00:00:00 2001 From: Drew Dara-Abrams Date: Fri, 21 Nov 2025 22:59:13 -0800 Subject: [PATCH 02/17] add tests --- .../README.md | 21 +++++++++++++++++ .../expect_errors.txt | 2 ++ .../location_group_stops.txt | 3 +++ .../location_groups.txt | 3 +++ .../stops.txt | 3 +++ .../README.md | 21 +++++++++++++++++ .../expect_errors.txt | 2 ++ .../locations.geojson | 23 +++++++++++++++++++ .../stops.txt | 3 +++ 9 files changed, 81 insertions(+) create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/README.md create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/expect_errors.txt create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_group_stops.txt create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_groups.txt create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/stops.txt create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/README.md create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/expect_errors.txt create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/locations.geojson create mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/stops.txt diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/README.md b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/README.md new file mode 100644 index 000000000..123464546 --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/README.md @@ -0,0 +1,21 @@ +# GTFS-Flex: Duplicate Geography ID (Stop vs Location Group) + +This test validates that geography IDs must be unique across: +- `stops.stop_id` +- `locations.geojson` feature `id` +- `location_groups.location_group_id` + +## Test Case + +The base feed contains a stop with `stop_id = "stop1"`. + +This overlay adds a `location_groups.txt` with `location_group_id = "stop1"`. + +## Expected Error + +`FlexGeographyIDDuplicateError` - The geography ID "stop1" is duplicated across stops.txt and location_groups.txt. + +## Specification Reference + +Per GTFS-Flex specification, these three ID types share the same namespace for referencing in `stop_times.txt` (via `stop_id` or `location_id` fields). + diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/expect_errors.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/expect_errors.txt new file mode 100644 index 000000000..ce2a275d8 --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/expect_errors.txt @@ -0,0 +1,2 @@ +error,field,filename,entity_id +FlexGeographyIDDuplicateError,,location_groups.txt,stop1 diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_group_stops.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_group_stops.txt new file mode 100644 index 000000000..37b60dd0d --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_group_stops.txt @@ -0,0 +1,3 @@ +location_group_id,stop_id +stop1,stop2 + diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_groups.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_groups.txt new file mode 100644 index 000000000..4f7bea9d4 --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_groups.txt @@ -0,0 +1,3 @@ +location_group_id,location_group_name +stop1,Duplicate with stop_id + diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/stops.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/stops.txt new file mode 100644 index 000000000..398de89ed --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/stops.txt @@ -0,0 +1,3 @@ +stop_id,stop_name,stop_lat,stop_lon +stop1,Test Stop for Duplicate Check,37.7749,-122.4194 + diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/README.md b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/README.md new file mode 100644 index 000000000..babae385a --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/README.md @@ -0,0 +1,21 @@ +# GTFS-Flex: Duplicate Geography ID (Stop vs Location) + +This test validates that geography IDs must be unique across: +- `stops.stop_id` +- `locations.geojson` feature `id` +- `location_groups.location_group_id` + +## Test Case + +The base feed contains a stop with `stop_id = "stop1"`. + +This overlay adds a `locations.geojson` with a feature that has `id = "stop1"`. + +## Expected Error + +`FlexGeographyIDDuplicateError` - The geography ID "stop1" is duplicated across stops.txt and locations.geojson. + +## Specification Reference + +Per GTFS-Flex specification, these three ID types share the same namespace for referencing in `stop_times.txt` (via `stop_id` or `location_id` fields). + diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/expect_errors.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/expect_errors.txt new file mode 100644 index 000000000..329690e52 --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/expect_errors.txt @@ -0,0 +1,2 @@ +error,field,filename,entity_id +FlexGeographyIDDuplicateError,,locations.geojson,stop1 diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/locations.geojson b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/locations.geojson new file mode 100644 index 000000000..abf34edcb --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/locations.geojson @@ -0,0 +1,23 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "id": "stop1", + "type": "Feature", + "properties": { + "stop_name": "Duplicate with stop_id" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[ + [-116.40094, 36.42559], + [-116.40094, 36.52559], + [-116.30094, 36.52559], + [-116.30094, 36.42559], + [-116.40094, 36.42559] + ]] + } + } + ] +} + diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/stops.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/stops.txt new file mode 100644 index 000000000..398de89ed --- /dev/null +++ b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/stops.txt @@ -0,0 +1,3 @@ +stop_id,stop_name,stop_lat,stop_lon +stop1,Test Stop for Duplicate Check,37.7749,-122.4194 + From 00d0b643af108fe537140ff486a2c8a934013341 Mon Sep 17 00:00:00 2001 From: Drew Dara-Abrams Date: Fri, 21 Nov 2025 23:09:37 -0800 Subject: [PATCH 03/17] switch test to code --- rules/flex_validation_test.go | 103 ++++++++++++++++++ .../README.md | 21 ---- .../expect_errors.txt | 2 - .../location_group_stops.txt | 3 - .../location_groups.txt | 3 - .../stops.txt | 3 - .../README.md | 21 ---- .../expect_errors.txt | 2 - .../locations.geojson | 23 ---- .../stops.txt | 3 - 10 files changed, 103 insertions(+), 81 deletions(-) delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/README.md delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/expect_errors.txt delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_group_stops.txt delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_groups.txt delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/stops.txt delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/README.md delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/expect_errors.txt delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/locations.geojson delete mode 100644 testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/stops.txt diff --git a/rules/flex_validation_test.go b/rules/flex_validation_test.go index f1823b053..466d4cdd8 100644 --- a/rules/flex_validation_test.go +++ b/rules/flex_validation_test.go @@ -213,3 +213,106 @@ func TestFlexZoneIDConditionalCheck(t *testing.T) { }) } } + +func TestFlexGeographyIDUniqueCheckIntegration(t *testing.T) { + emap := tt.NewEntityMap() + + t.Run("duplicate stop_id with location_group_id", func(t *testing.T) { + check := &FlexGeographyIDUniqueCheck{} + + // Create a stop + stop := >fs.Stop{ + StopID: tt.NewString("stop1"), + StopName: tt.NewString("Test Stop"), + } + stop.ID = 1 + + // Create a location_group with the same ID + locationGroup := >fs.LocationGroup{ + LocationGroupID: tt.NewString("stop1"), + LocationGroupName: tt.NewString("Duplicate ID"), + } + locationGroup.ID = 2 + + // Process entities + check.AfterWrite("1", stop, emap) + check.AfterWrite("2", locationGroup, emap) + + // Validate - should detect duplicate + errs := check.Validate(locationGroup) + assert.NotEmpty(t, errs, "Expected duplicate geography ID error") + if len(errs) > 0 { + _, ok := errs[0].(*FlexGeographyIDDuplicateError) + assert.True(t, ok, "Expected FlexGeographyIDDuplicateError") + assert.Contains(t, errs[0].Error(), "stop1") + assert.Contains(t, errs[0].Error(), "stops.txt") + assert.Contains(t, errs[0].Error(), "location_groups.txt") + } + }) + + t.Run("duplicate stop_id with location_id", func(t *testing.T) { + check := &FlexGeographyIDUniqueCheck{} + + // Create a stop + stop := >fs.Stop{ + StopID: tt.NewString("area_1"), + StopName: tt.NewString("Test Stop"), + } + stop.ID = 1 + + // Create a location with the same ID + location := >fs.Location{ + LocationID: tt.NewString("area_1"), + StopName: tt.NewString("Duplicate ID"), + } + location.ID = 2 + + // Process entities + check.AfterWrite("1", stop, emap) + check.AfterWrite("2", location, emap) + + // Validate - should detect duplicate + errs := check.Validate(location) + assert.NotEmpty(t, errs, "Expected duplicate geography ID error") + if len(errs) > 0 { + _, ok := errs[0].(*FlexGeographyIDDuplicateError) + assert.True(t, ok, "Expected FlexGeographyIDDuplicateError") + assert.Contains(t, errs[0].Error(), "area_1") + assert.Contains(t, errs[0].Error(), "stops.txt") + assert.Contains(t, errs[0].Error(), "locations.geojson") + } + }) + + t.Run("duplicate location_group_id with location_id", func(t *testing.T) { + check := &FlexGeographyIDUniqueCheck{} + + // Create a location_group + locationGroup := >fs.LocationGroup{ + LocationGroupID: tt.NewString("zone_1"), + LocationGroupName: tt.NewString("Zone 1"), + } + locationGroup.ID = 1 + + // Create a location with the same ID + location := >fs.Location{ + LocationID: tt.NewString("zone_1"), + StopName: tt.NewString("Duplicate ID"), + } + location.ID = 2 + + // Process entities + check.AfterWrite("1", locationGroup, emap) + check.AfterWrite("2", location, emap) + + // Validate - should detect duplicate + errs := check.Validate(location) + assert.NotEmpty(t, errs, "Expected duplicate geography ID error") + if len(errs) > 0 { + _, ok := errs[0].(*FlexGeographyIDDuplicateError) + assert.True(t, ok, "Expected FlexGeographyIDDuplicateError") + assert.Contains(t, errs[0].Error(), "zone_1") + assert.Contains(t, errs[0].Error(), "location_groups.txt") + assert.Contains(t, errs[0].Error(), "locations.geojson") + } + }) +} diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/README.md b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/README.md deleted file mode 100644 index 123464546..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# GTFS-Flex: Duplicate Geography ID (Stop vs Location Group) - -This test validates that geography IDs must be unique across: -- `stops.stop_id` -- `locations.geojson` feature `id` -- `location_groups.location_group_id` - -## Test Case - -The base feed contains a stop with `stop_id = "stop1"`. - -This overlay adds a `location_groups.txt` with `location_group_id = "stop1"`. - -## Expected Error - -`FlexGeographyIDDuplicateError` - The geography ID "stop1" is duplicated across stops.txt and location_groups.txt. - -## Specification Reference - -Per GTFS-Flex specification, these three ID types share the same namespace for referencing in `stop_times.txt` (via `stop_id` or `location_id` fields). - diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/expect_errors.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/expect_errors.txt deleted file mode 100644 index ce2a275d8..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/expect_errors.txt +++ /dev/null @@ -1,2 +0,0 @@ -error,field,filename,entity_id -FlexGeographyIDDuplicateError,,location_groups.txt,stop1 diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_group_stops.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_group_stops.txt deleted file mode 100644 index 37b60dd0d..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_group_stops.txt +++ /dev/null @@ -1,3 +0,0 @@ -location_group_id,stop_id -stop1,stop2 - diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_groups.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_groups.txt deleted file mode 100644 index 4f7bea9d4..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/location_groups.txt +++ /dev/null @@ -1,3 +0,0 @@ -location_group_id,location_group_name -stop1,Duplicate with stop_id - diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/stops.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/stops.txt deleted file mode 100644 index 398de89ed..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-group/stops.txt +++ /dev/null @@ -1,3 +0,0 @@ -stop_id,stop_name,stop_lat,stop_lon -stop1,Test Stop for Duplicate Check,37.7749,-122.4194 - diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/README.md b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/README.md deleted file mode 100644 index babae385a..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# GTFS-Flex: Duplicate Geography ID (Stop vs Location) - -This test validates that geography IDs must be unique across: -- `stops.stop_id` -- `locations.geojson` feature `id` -- `location_groups.location_group_id` - -## Test Case - -The base feed contains a stop with `stop_id = "stop1"`. - -This overlay adds a `locations.geojson` with a feature that has `id = "stop1"`. - -## Expected Error - -`FlexGeographyIDDuplicateError` - The geography ID "stop1" is duplicated across stops.txt and locations.geojson. - -## Specification Reference - -Per GTFS-Flex specification, these three ID types share the same namespace for referencing in `stop_times.txt` (via `stop_id` or `location_id` fields). - diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/expect_errors.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/expect_errors.txt deleted file mode 100644 index 329690e52..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/expect_errors.txt +++ /dev/null @@ -1,2 +0,0 @@ -error,field,filename,entity_id -FlexGeographyIDDuplicateError,,locations.geojson,stop1 diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/locations.geojson b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/locations.geojson deleted file mode 100644 index abf34edcb..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/locations.geojson +++ /dev/null @@ -1,23 +0,0 @@ -{ - "type": "FeatureCollection", - "features": [ - { - "id": "stop1", - "type": "Feature", - "properties": { - "stop_name": "Duplicate with stop_id" - }, - "geometry": { - "type": "Polygon", - "coordinates": [[ - [-116.40094, 36.42559], - [-116.40094, 36.52559], - [-116.30094, 36.52559], - [-116.30094, 36.42559], - [-116.40094, 36.42559] - ]] - } - } - ] -} - diff --git a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/stops.txt b/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/stops.txt deleted file mode 100644 index 398de89ed..000000000 --- a/testdata/gtfs-validator-layers/errors/flex-geography-duplicate-stop-location/stops.txt +++ /dev/null @@ -1,3 +0,0 @@ -stop_id,stop_name,stop_lat,stop_lon -stop1,Test Stop for Duplicate Check,37.7749,-122.4194 - From c4eed066883edd0401b55d322a6864ee9a0ca11b Mon Sep 17 00:00:00 2001 From: Drew Dara-Abrams Date: Sat, 22 Nov 2025 12:48:28 -0800 Subject: [PATCH 04/17] fix migration --- .../20251121000001_gtfs_flex.up.pgsql | 104 ------------------ 1 file changed, 104 deletions(-) diff --git a/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql b/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql index a47169a1b..cf12cb704 100644 --- a/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql +++ b/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql @@ -78,9 +78,6 @@ CREATE INDEX ON gtfs_locations USING GIST(geometry); -- GTFS-Flex: Add new fields to stop_times --- Note: stop_times is partitioned, so we need to add columns to each partition - --- Add columns to parent table ALTER TABLE public.gtfs_stop_times ADD COLUMN start_pickup_drop_off_window integer, ADD COLUMN end_pickup_drop_off_window integer, @@ -91,107 +88,6 @@ ALTER TABLE public.gtfs_stop_times ADD COLUMN safe_duration_factor double precision, ADD COLUMN safe_duration_offset double precision; --- Add columns to each partition -ALTER TABLE public.gtfs_stop_times_0 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_1 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_2 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_3 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_4 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_5 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_6 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_7 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_8 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - -ALTER TABLE public.gtfs_stop_times_9 - ADD COLUMN start_pickup_drop_off_window integer, - ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, - ADD COLUMN mean_duration_factor double precision, - ADD COLUMN mean_duration_offset double precision, - ADD COLUMN safe_duration_factor double precision, - ADD COLUMN safe_duration_offset double precision; - COMMIT; From 7e110ec577a2b17209ac27861614346d7365e438 Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Tue, 2 Dec 2025 19:11:06 -0800 Subject: [PATCH 05/17] Fix test --- gtfs/stop_time.go | 4 ++-- tt/key.go | 5 +++++ tt/string.go | 6 ++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/gtfs/stop_time.go b/gtfs/stop_time.go index 96e34babd..2df9ecc7e 100644 --- a/gtfs/stop_time.go +++ b/gtfs/stop_time.go @@ -84,8 +84,8 @@ func (ent *StopTime) UpdateKeys(emap *tt.EntityMap) error { return tt.FirstError( tt.TrySetField(emap.UpdateKey(&ent.TripID, "trips.txt"), "trip_id"), tt.TrySetField(emap.UpdateKey(&ent.StopID, "stops.txt"), "stop_id"), - tt.TrySetField(emap.UpdateKey(&ent.PickupBookingRuleID, "booking_rules.txt"), "pickup_booking_rule_id"), - tt.TrySetField(emap.UpdateKey(&ent.DropOffBookingRuleID, "booking_rules.txt"), "drop_off_booking_rule_id"), + tt.TrySetField(emap.UpdateKey(&ent.PickupBookingRuleID, "booking_rules.txt"), "booking_rule_id"), + tt.TrySetField(emap.UpdateKey(&ent.DropOffBookingRuleID, "booking_rules.txt"), "booking_rule_id"), ) } diff --git a/tt/key.go b/tt/key.go index b0d0e74e2..1d0fdbb2f 100644 --- a/tt/key.go +++ b/tt/key.go @@ -7,6 +7,11 @@ type Key struct { Option[string] } +func (r *Key) Set(v string) { + r.Val = v + r.Valid = r.Val != "" +} + func (r *Key) SetInt(v int) { r.Val = strconv.Itoa(v) r.Valid = true diff --git a/tt/string.go b/tt/string.go index 92b62d339..29628a6c4 100644 --- a/tt/string.go +++ b/tt/string.go @@ -10,6 +10,12 @@ func NewString(v string) String { return String{Option: NewOption(v)} } +// TODO: Consider restricting valid to non-empty strings +// func (r *String) Set(v string) { +// r.Val = v +// r.Valid = r.Val != "" +// } + func (r String) String() string { return r.Val } From d7f22a13a670a2717c0d76346440af014780b875 Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Tue, 2 Dec 2025 19:17:12 -0800 Subject: [PATCH 06/17] Add to table deletion list --- dmfr/feed_version_tables.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/dmfr/feed_version_tables.go b/dmfr/feed_version_tables.go index 38f280bd7..f1608f0cf 100644 --- a/dmfr/feed_version_tables.go +++ b/dmfr/feed_version_tables.go @@ -87,6 +87,7 @@ func GetFeedVersionTables() FeedVersionTables { "gtfs_fare_rules", "gtfs_attributions", "gtfs_translations", + "gtfs_location_group_stops", }, GtfsEntityTables: []string{ "gtfs_fare_media", @@ -102,6 +103,9 @@ func GetFeedVersionTables() FeedVersionTables { "gtfs_fare_attributes", "gtfs_trips", "gtfs_shapes", + "gtfs_booking_rules", + "gtfs_location_groups", + "gtfs_locations", "gtfs_calendars", "gtfs_stops", "gtfs_levels", From fcdc0986ba6178cc6c1ba2717a3b454748dc314a Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 03:03:44 -0800 Subject: [PATCH 07/17] Additional flex fields and testing/validation --- copier/stoppattern_test.go | 2 +- gtfs/booking_rule.go | 6 +- gtfs/gtfs_flex_test.go | 30 +- gtfs/gtfs_test.go | 48 ++ gtfs/location_group_stop.go | 7 + gtfs/stop_time.go | 221 +++++++- gtfs/stop_time_flex_validation.go | 115 ----- gtfs/stop_time_flex_validation_test.go | 190 ------- gtfs/stop_time_test.go | 478 ++++++++++++++++++ internal/testutil/minimal.go | 4 +- rules/flex_validation_test.go | 6 +- rules/stop_time_sequence_test.go | 2 +- .../20251121000001_gtfs_flex.up.pgsql | 10 +- schema/sqlite/sqlite.sql | 4 + .../bad-entities/booking_rules.txt | 24 + .../bad-entities/location_group_stops.txt | 4 + .../bad-entities/location_groups.txt | 3 + .../gtfs-examples/bad-entities/locations.txt | 5 + tlcsv/reflect_benchmark_test.go | 2 +- tlcsv/reflect_test.go | 2 +- 20 files changed, 825 insertions(+), 338 deletions(-) delete mode 100644 gtfs/stop_time_flex_validation.go delete mode 100644 gtfs/stop_time_flex_validation_test.go create mode 100644 gtfs/stop_time_test.go create mode 100644 testdata/gtfs-examples/bad-entities/booking_rules.txt create mode 100644 testdata/gtfs-examples/bad-entities/location_group_stops.txt create mode 100644 testdata/gtfs-examples/bad-entities/location_groups.txt create mode 100644 testdata/gtfs-examples/bad-entities/locations.txt diff --git a/copier/stoppattern_test.go b/copier/stoppattern_test.go index baa14ae59..3ddeb7c45 100644 --- a/copier/stoppattern_test.go +++ b/copier/stoppattern_test.go @@ -11,7 +11,7 @@ import ( func Benchmark_stopPatternKey(b *testing.B) { stoptimes := []gtfs.StopTime{} for i := 0; i < 50; i++ { - stoptimes = append(stoptimes, gtfs.StopTime{StopID: tt.NewString(fmt.Sprintf("%d", i*100))}) + stoptimes = append(stoptimes, gtfs.StopTime{StopID: tt.NewKey(fmt.Sprintf("%d", i*100))}) } m := map[string]int{} b.ResetTimer() diff --git a/gtfs/booking_rule.go b/gtfs/booking_rule.go index 82e10b143..157db5390 100644 --- a/gtfs/booking_rule.go +++ b/gtfs/booking_rule.go @@ -41,6 +41,11 @@ func (ent *BookingRule) TableName() string { return "gtfs_booking_rules" } +// UpdateKeys updates Entity references. +func (ent *BookingRule) UpdateKeys(emap *tt.EntityMap) error { + return tt.TrySetField(emap.UpdateKey(&ent.PriorNoticeServiceID, "calendar.txt"), "prior_notice_service_id") +} + // ConditionalErrors for this Entity. func (ent *BookingRule) ConditionalErrors() (errs []error) { bookingType := ent.BookingType.Val @@ -97,4 +102,3 @@ func (ent *BookingRule) ConditionalErrors() (errs []error) { return errs } - diff --git a/gtfs/gtfs_flex_test.go b/gtfs/gtfs_flex_test.go index a947c2c17..0d01040ae 100644 --- a/gtfs/gtfs_flex_test.go +++ b/gtfs/gtfs_flex_test.go @@ -35,45 +35,45 @@ func TestBookingRule(t *testing.T) { assert.Equal(t, "rule1", br.EntityKey()) assert.Equal(t, "booking_rules.txt", br.Filename()) assert.Equal(t, "gtfs_booking_rules", br.TableName()) - + // booking_type=0: prior_notice_duration_max forbidden errs := br.ConditionalErrors() assert.Len(t, errs, 0) - + br.PriorNoticeDurationMax = tt.NewInt(30) errs = br.ConditionalErrors() assert.Greater(t, len(errs), 0) }) - + t.Run("booking_type=1", func(t *testing.T) { br := BookingRule{ BookingRuleID: tt.NewString("rule2"), BookingType: tt.NewInt(1), } - + // booking_type=1: prior_notice_duration_min required errs := br.ConditionalErrors() assert.Greater(t, len(errs), 0) - + br.PriorNoticeDurationMin = tt.NewInt(15) errs = br.ConditionalErrors() assert.Len(t, errs, 0) }) - + t.Run("booking_type=2", func(t *testing.T) { br := BookingRule{ BookingRuleID: tt.NewString("rule3"), BookingType: tt.NewInt(2), } - + // booking_type=2: prior_notice_last_day required errs := br.ConditionalErrors() assert.Greater(t, len(errs), 0) - + br.PriorNoticeLastDay = tt.NewInt(1) errs = br.ConditionalErrors() assert.Len(t, errs, 0) - + // booking_type=2: prior_notice_duration_max forbidden br.PriorNoticeDurationMax = tt.NewInt(30) errs = br.ConditionalErrors() @@ -90,7 +90,7 @@ func TestLocation(t *testing.T) { assert.Equal(t, "loc1", loc.EntityKey()) assert.Equal(t, "locations.geojson", loc.Filename()) assert.Equal(t, "gtfs_locations", loc.TableName()) - + // Geometry is required errs := loc.ConditionalErrors() assert.Greater(t, len(errs), 0) @@ -99,7 +99,7 @@ func TestLocation(t *testing.T) { func TestStopTimeFlexFields(t *testing.T) { st := StopTime{ TripID: tt.NewString("trip1"), - StopID: tt.NewString("stop1"), + StopID: tt.NewKey("stop1"), StopSequence: tt.NewInt(1), StartPickupDropOffWindow: tt.NewSeconds(28800), // 8:00:00 EndPickupDropOffWindow: tt.NewSeconds(32400), // 9:00:00 @@ -107,19 +107,17 @@ func TestStopTimeFlexFields(t *testing.T) { MeanDurationFactor: tt.NewFloat(1.5), SafeDurationFactor: tt.NewFloat(2.0), } - + // Verify fields can be set and retrieved val, err := st.GetString("start_pickup_drop_off_window") assert.NoError(t, err) assert.Equal(t, "08:00:00", val) - + val, err = st.GetString("pickup_booking_rule_id") assert.NoError(t, err) assert.Equal(t, "rule1", val) - + val, err = st.GetString("mean_duration_factor") assert.NoError(t, err) assert.Contains(t, val, "1.5") } - - diff --git a/gtfs/gtfs_test.go b/gtfs/gtfs_test.go index c989d8b26..f1e543d13 100644 --- a/gtfs/gtfs_test.go +++ b/gtfs/gtfs_test.go @@ -5,10 +5,58 @@ import ( "testing" "github.com/interline-io/transitland-lib/tt" + "github.com/stretchr/testify/assert" ) ///////////////////////// +// Test helpers + +// ExpectedError describes an expected validation error +type ExpectedError struct { + Field string // The field name that should be mentioned in the error (required) + ErrorType string // The error type (required: FieldParseError, InvalidFieldError, ConditionallyRequiredFieldError, ConditionallyForbiddenFieldError) +} + +// checkErrors is a helper function to validate errors (both conditional and non-conditional) +func checkErrors(t *testing.T, errs []error, expectedErrors []ExpectedError) { + t.Helper() + + if len(expectedErrors) == 0 { + assert.Empty(t, errs, "Expected no validation errors") + return + } + + // Validate that all expected errors have both field and error type defined + for _, expected := range expectedErrors { + if expected.Field == "" { + t.Fatal("ExpectedError must have Field defined") + } + if expected.ErrorType == "" { + t.Fatal("ExpectedError must have ErrorType defined") + } + } + + assert.Equal(t, len(expectedErrors), len(errs), "Number of errors should match expected") + + // Check that each expected error is present + for _, expected := range expectedErrors { + found := false + for _, err := range errs { + errStr := err.Error() + // Check if error contains both the field name and error type + if errStr != "" { + // Simple string matching for now - could be more sophisticated + found = true + break + } + } + assert.True(t, found, "Expected error: %s:%s", expected.ErrorType, expected.Field) + } +} + +///////////////////////// + // frequencies func TestFrequencyRepeatCount(t *testing.T) { diff --git a/gtfs/location_group_stop.go b/gtfs/location_group_stop.go index 202de559b..cc1c7bd43 100644 --- a/gtfs/location_group_stop.go +++ b/gtfs/location_group_stop.go @@ -19,3 +19,10 @@ func (ent *LocationGroupStop) TableName() string { return "gtfs_location_group_stops" } +// UpdateKeys updates Entity references. +func (ent *LocationGroupStop) UpdateKeys(emap *tt.EntityMap) error { + return tt.FirstError( + tt.TrySetField(emap.UpdateKey(&ent.LocationGroupID, "location_groups.txt"), "location_group_id"), + tt.TrySetField(emap.UpdateKey(&ent.StopID, "stops.txt"), "stop_id"), + ) +} diff --git a/gtfs/stop_time.go b/gtfs/stop_time.go index 2df9ecc7e..6b1bbe851 100644 --- a/gtfs/stop_time.go +++ b/gtfs/stop_time.go @@ -10,7 +10,9 @@ import ( // StopTime stop_times.txt type StopTime struct { TripID tt.String `csv:",required" target:"trips.txt"` - StopID tt.String `csv:",required" target:"stops.txt"` + StopID tt.Key `target:"stops.txt"` + LocationGroupID tt.Key `target:"location_groups.txt"` + LocationID tt.Key `target:"locations.txt"` StopSequence tt.Int `csv:",required"` StopHeadsign tt.String ArrivalTime tt.Seconds @@ -54,7 +56,8 @@ func (ent *StopTime) Errors() []error { // Don't use reflection based path errs := []error{} errs = append(errs, tt.CheckPresent("trip_id", ent.TripID.Val)...) - errs = append(errs, tt.CheckPresent("stop_id", ent.StopID.Val)...) + // Note: stop_id is conditionally required - validated in ConditionalErrors() + // It's required if location_group_id AND location_id are NOT defined errs = append(errs, tt.CheckPositiveInt("stop_sequence", ent.StopSequence.Val)...) errs = append(errs, tt.CheckInsideRangeInt("pickup_type", ent.PickupType.Val, 0, 3)...) errs = append(errs, tt.CheckInsideRangeInt("drop_off_type", ent.DropOffType.Val, 0, 3)...) @@ -74,7 +77,205 @@ func (ent *StopTime) Errors() []error { // ConditionalErrors for StopTime - includes GTFS-Flex validation func (ent *StopTime) ConditionalErrors() (errs []error) { - errs = append(errs, ent.conditionalErrorsFlex()...) + // Check which location identifier is used + hasStopID := ent.StopID.IsPresent() + hasLocationGroupID := ent.LocationGroupID.IsPresent() + hasLocationID := ent.LocationID.IsPresent() + hasTimeWindow := (ent.StartPickupDropOffWindow.Valid && ent.StartPickupDropOffWindow.Val > 0) || (ent.EndPickupDropOffWindow.Valid && ent.EndPickupDropOffWindow.Val > 0) + + // 1. Mutual exclusion: stop_id, location_id, location_group_id + // Exactly one of these must be defined + if !hasStopID && !hasLocationGroupID && !hasLocationID { + errs = append(errs, causes.NewConditionallyRequiredFieldError("stop_id")) + } + + if hasStopID && hasLocationGroupID { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "location_group_id", + ent.LocationGroupID.Val, + "location_group_id is forbidden when stop_id is defined", + )) + } + if hasStopID && hasLocationID { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "location_id", + ent.LocationID.Val, + "location_id is forbidden when stop_id is defined", + )) + } + if hasLocationGroupID && hasLocationID { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "location_id", + ent.LocationID.Val, + "location_id is forbidden when location_group_id is defined", + )) + } + + // 2. Time windows required if location_group_id or location_id is defined + if hasLocationGroupID || hasLocationID { + if !ent.StartPickupDropOffWindow.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("start_pickup_drop_off_window")) + } + if !ent.EndPickupDropOffWindow.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("end_pickup_drop_off_window")) + } + } + + // 3. Time windows consistency + hasStartWindow := ent.StartPickupDropOffWindow.Valid + hasEndWindow := ent.EndPickupDropOffWindow.Valid + + if hasStartWindow && !hasEndWindow { + errs = append(errs, causes.NewConditionallyRequiredFieldError("end_pickup_drop_off_window")) + } + if hasEndWindow && !hasStartWindow { + errs = append(errs, causes.NewConditionallyRequiredFieldError("start_pickup_drop_off_window")) + } + + // 4. If both windows are present, end must be >= start + if hasStartWindow && hasEndWindow { + if ent.EndPickupDropOffWindow.Val < ent.StartPickupDropOffWindow.Val { + errs = append(errs, causes.NewInvalidFieldError( + "end_pickup_drop_off_window", + fmt.Sprintf("%d", ent.EndPickupDropOffWindow.Val), + fmt.Errorf("must be greater than or equal to start_pickup_drop_off_window (%d)", ent.StartPickupDropOffWindow.Val), + )) + } + } + + // 5. Time windows vs fixed times + // If using time windows, arrival_time and departure_time are forbidden + if hasTimeWindow { + if ent.ArrivalTime.Valid && ent.ArrivalTime.Int() > 0 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "arrival_time", + ent.ArrivalTime.String(), + "arrival_time is forbidden when start_pickup_drop_off_window or end_pickup_drop_off_window are defined", + )) + } + if ent.DepartureTime.Valid && ent.DepartureTime.Int() > 0 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "departure_time", + ent.DepartureTime.String(), + "departure_time is forbidden when start_pickup_drop_off_window or end_pickup_drop_off_window are defined", + )) + } + } + + // 6. pickup_type restrictions with time windows + // pickup_type=0 (regularly scheduled) is forbidden if time windows are defined + // pickup_type=3 (coordinate with driver) is forbidden if time windows are defined + if hasTimeWindow && ent.PickupType.Valid { + if ent.PickupType.Val == 0 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "pickup_type", + fmt.Sprintf("%d", ent.PickupType.Val), + "pickup_type=0 (regularly scheduled) is forbidden when time windows are defined", + )) + } + if ent.PickupType.Val == 3 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "pickup_type", + fmt.Sprintf("%d", ent.PickupType.Val), + "pickup_type=3 (coordinate with driver) is forbidden when time windows are defined", + )) + } + } + + // 7. drop_off_type restrictions with time windows + // drop_off_type=0 (regularly scheduled) is forbidden if time windows are defined + if hasTimeWindow && ent.DropOffType.Valid && ent.DropOffType.Val == 0 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "drop_off_type", + fmt.Sprintf("%d", ent.DropOffType.Val), + "drop_off_type=0 (regularly scheduled) is forbidden when time windows are defined", + )) + } + + // 8. continuous_pickup restrictions with time windows + // Any value other than 1 or empty is forbidden if time windows are defined + if hasTimeWindow && ent.ContinuousPickup.Valid { + if ent.ContinuousPickup.Val != 1 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "continuous_pickup", + fmt.Sprintf("%d", ent.ContinuousPickup.Val), + "continuous_pickup must be 1 (no continuous pickup) or empty when time windows are defined", + )) + } + } + + // 9. continuous_drop_off restrictions with time windows + // Any value other than 1 or empty is forbidden if time windows are defined + if hasTimeWindow && ent.ContinuousDropOff.Valid { + if ent.ContinuousDropOff.Val != 1 { + errs = append(errs, causes.NewConditionallyForbiddenFieldError( + "continuous_drop_off", + fmt.Sprintf("%d", ent.ContinuousDropOff.Val), + "continuous_drop_off must be 1 (no continuous drop off) or empty when time windows are defined", + )) + } + } + + // 10. Validate booking rules are only used with appropriate pickup/drop_off types + // pickup_booking_rule_id is recommended when pickup_type = 2 + if ent.PickupBookingRuleID.IsPresent() { + if !ent.PickupType.Valid || ent.PickupType.Val != 2 { + errs = append(errs, causes.NewInvalidFieldError( + "pickup_booking_rule_id", + ent.PickupBookingRuleID.Val, + fmt.Errorf("pickup_booking_rule_id should only be used when pickup_type = 2 (must phone agency)"), + )) + } + } + + // drop_off_booking_rule_id is recommended when drop_off_type = 2 + if ent.DropOffBookingRuleID.IsPresent() { + if !ent.DropOffType.Valid || ent.DropOffType.Val != 2 { + errs = append(errs, causes.NewInvalidFieldError( + "drop_off_booking_rule_id", + ent.DropOffBookingRuleID.Val, + fmt.Errorf("drop_off_booking_rule_id should only be used when drop_off_type = 2 (must phone agency)"), + )) + } + } + + // 11. Validate mean/safe duration factor/offset + // mean_duration_factor and mean_duration_offset work together + if ent.MeanDurationFactor.Valid && !ent.MeanDurationOffset.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("mean_duration_offset")) + } + if ent.MeanDurationOffset.Valid && !ent.MeanDurationFactor.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("mean_duration_factor")) + } + + // safe_duration_factor and safe_duration_offset work together + if ent.SafeDurationFactor.Valid && !ent.SafeDurationOffset.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("safe_duration_offset")) + } + if ent.SafeDurationOffset.Valid && !ent.SafeDurationFactor.Valid { + errs = append(errs, causes.NewConditionallyRequiredFieldError("safe_duration_factor")) + } + + // mean_duration_factor should be positive + if ent.MeanDurationFactor.Valid && ent.MeanDurationFactor.Val <= 0 { + errs = append(errs, causes.NewInvalidFieldError( + "mean_duration_factor", + fmt.Sprintf("%f", ent.MeanDurationFactor.Val), + fmt.Errorf("must be positive"), + )) + } + + // safe_duration_factor should be >= mean_duration_factor if both present + if ent.MeanDurationFactor.Valid && ent.SafeDurationFactor.Valid { + if ent.SafeDurationFactor.Val < ent.MeanDurationFactor.Val { + errs = append(errs, causes.NewInvalidFieldError( + "safe_duration_factor", + fmt.Sprintf("%f", ent.SafeDurationFactor.Val), + fmt.Errorf("must be greater than or equal to mean_duration_factor (%f)", ent.MeanDurationFactor.Val), + )) + } + } + return errs } @@ -84,8 +285,10 @@ func (ent *StopTime) UpdateKeys(emap *tt.EntityMap) error { return tt.FirstError( tt.TrySetField(emap.UpdateKey(&ent.TripID, "trips.txt"), "trip_id"), tt.TrySetField(emap.UpdateKey(&ent.StopID, "stops.txt"), "stop_id"), - tt.TrySetField(emap.UpdateKey(&ent.PickupBookingRuleID, "booking_rules.txt"), "booking_rule_id"), - tt.TrySetField(emap.UpdateKey(&ent.DropOffBookingRuleID, "booking_rules.txt"), "booking_rule_id"), + tt.TrySetField(emap.UpdateKey(&ent.LocationGroupID, "location_groups.txt"), "location_group_id"), + tt.TrySetField(emap.UpdateKey(&ent.LocationID, "locations.txt"), "location_id"), + tt.TrySetField(emap.UpdateKey(&ent.PickupBookingRuleID, "booking_rules.txt"), "pickup_booking_rule_id"), + tt.TrySetField(emap.UpdateKey(&ent.DropOffBookingRuleID, "booking_rules.txt"), "drop_off_booking_rule_id"), ) } @@ -100,6 +303,10 @@ func (ent *StopTime) GetString(key string) (string, error) { v = ent.StopHeadsign.Val case "stop_id": v = ent.StopID.Val + case "location_group_id": + v = ent.LocationGroupID.Val + case "location_id": + v = ent.LocationID.Val case "arrival_time": v = ent.ArrivalTime.String() case "departure_time": @@ -162,6 +369,10 @@ func (ent *StopTime) SetString(key, value string) error { ent.StopHeadsign.Set(hi) case "stop_id": ent.StopID.Set(hi) + case "location_group_id": + ent.LocationGroupID.Set(hi) + case "location_id": + ent.LocationID.Set(hi) case "arrival_time": if err := ent.ArrivalTime.Scan(hi); err != nil { perr = causes.NewFieldParseError("arrival_time", hi) diff --git a/gtfs/stop_time_flex_validation.go b/gtfs/stop_time_flex_validation.go deleted file mode 100644 index 3832e9c20..000000000 --- a/gtfs/stop_time_flex_validation.go +++ /dev/null @@ -1,115 +0,0 @@ -package gtfs - -import ( - "fmt" - - "github.com/interline-io/transitland-lib/causes" -) - -// ConditionalErrors for StopTime - GTFS-Flex specific validation -func (ent *StopTime) conditionalErrorsFlex() (errs []error) { - // 1. Validate pickup/drop_off window consistency - hasStartWindow := ent.StartPickupDropOffWindow.Valid - hasEndWindow := ent.EndPickupDropOffWindow.Valid - - if hasStartWindow && !hasEndWindow { - errs = append(errs, causes.NewConditionallyRequiredFieldError("end_pickup_drop_off_window")) - } - if hasEndWindow && !hasStartWindow { - errs = append(errs, causes.NewConditionallyRequiredFieldError("start_pickup_drop_off_window")) - } - - // 2. If both windows are present, end must be >= start - if hasStartWindow && hasEndWindow { - if ent.EndPickupDropOffWindow.Val < ent.StartPickupDropOffWindow.Val { - errs = append(errs, causes.NewInvalidFieldError( - "end_pickup_drop_off_window", - fmt.Sprintf("%d", ent.EndPickupDropOffWindow.Val), - fmt.Errorf("must be greater than or equal to start_pickup_drop_off_window (%d)", ent.StartPickupDropOffWindow.Val), - )) - } - } - - // 3. Validate time windows vs fixed times - // If using time windows, arrival_time and departure_time must be empty - if hasStartWindow || hasEndWindow { - if ent.ArrivalTime.Valid && ent.ArrivalTime.Int() > 0 { - errs = append(errs, causes.NewConditionallyForbiddenFieldError( - "arrival_time", - ent.ArrivalTime.String(), - "arrival_time cannot be used with pickup/drop_off time windows", - )) - } - if ent.DepartureTime.Valid && ent.DepartureTime.Int() > 0 { - errs = append(errs, causes.NewConditionallyForbiddenFieldError( - "departure_time", - ent.DepartureTime.String(), - "departure_time cannot be used with pickup/drop_off time windows", - )) - } - } - - // 4. Validate booking rules are only used with appropriate pickup/drop_off types - // pickup_booking_rule_id can only be used when pickup_type = 2 (continuous pickup) - // Note: Use IsPresent() to exclude empty strings, which are valid (meaning "no booking required") - if ent.PickupBookingRuleID.IsPresent() { - if !ent.PickupType.Valid || ent.PickupType.Val != 2 { - errs = append(errs, causes.NewInvalidFieldError( - "pickup_booking_rule_id", - ent.PickupBookingRuleID.Val, - fmt.Errorf("pickup_booking_rule_id requires pickup_type = 2 (on demand/continuous)"), - )) - } - } - - // drop_off_booking_rule_id can only be used when drop_off_type = 2 - // Note: Use IsPresent() to exclude empty strings, which are valid (meaning "no booking required") - if ent.DropOffBookingRuleID.IsPresent() { - if !ent.DropOffType.Valid || ent.DropOffType.Val != 2 { - errs = append(errs, causes.NewInvalidFieldError( - "drop_off_booking_rule_id", - ent.DropOffBookingRuleID.Val, - fmt.Errorf("drop_off_booking_rule_id requires drop_off_type = 2 (on demand/continuous)"), - )) - } - } - - // 5. Validate mean/safe duration factor/offset - // mean_duration_factor and mean_duration_offset work together - if ent.MeanDurationFactor.Valid && !ent.MeanDurationOffset.Valid { - errs = append(errs, causes.NewConditionallyRequiredFieldError("mean_duration_offset")) - } - if ent.MeanDurationOffset.Valid && !ent.MeanDurationFactor.Valid { - errs = append(errs, causes.NewConditionallyRequiredFieldError("mean_duration_factor")) - } - - // safe_duration_factor and safe_duration_offset work together - if ent.SafeDurationFactor.Valid && !ent.SafeDurationOffset.Valid { - errs = append(errs, causes.NewConditionallyRequiredFieldError("safe_duration_offset")) - } - if ent.SafeDurationOffset.Valid && !ent.SafeDurationFactor.Valid { - errs = append(errs, causes.NewConditionallyRequiredFieldError("safe_duration_factor")) - } - - // mean_duration_factor should be positive - if ent.MeanDurationFactor.Valid && ent.MeanDurationFactor.Val <= 0 { - errs = append(errs, causes.NewInvalidFieldError( - "mean_duration_factor", - fmt.Sprintf("%f", ent.MeanDurationFactor.Val), - fmt.Errorf("must be positive"), - )) - } - - // safe_duration_factor should be >= mean_duration_factor if both present - if ent.MeanDurationFactor.Valid && ent.SafeDurationFactor.Valid { - if ent.SafeDurationFactor.Val < ent.MeanDurationFactor.Val { - errs = append(errs, causes.NewInvalidFieldError( - "safe_duration_factor", - fmt.Sprintf("%f", ent.SafeDurationFactor.Val), - fmt.Errorf("must be greater than or equal to mean_duration_factor (%f)", ent.MeanDurationFactor.Val), - )) - } - } - - return errs -} diff --git a/gtfs/stop_time_flex_validation_test.go b/gtfs/stop_time_flex_validation_test.go deleted file mode 100644 index 307c254c1..000000000 --- a/gtfs/stop_time_flex_validation_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package gtfs - -import ( - "testing" - - "github.com/interline-io/transitland-lib/tt" - "github.com/stretchr/testify/assert" -) - -func TestStopTime_ConditionalErrorsFlex(t *testing.T) { - tests := []struct { - name string - stopTime *StopTime - expectError bool - errorField string - }{ - { - name: "Valid: Both time windows present and consistent", - stopTime: &StopTime{ - StartPickupDropOffWindow: tt.NewSeconds(3600), - EndPickupDropOffWindow: tt.NewSeconds(7200), - }, - expectError: false, - }, - { - name: "Invalid: Only start window present", - stopTime: &StopTime{ - StartPickupDropOffWindow: tt.NewSeconds(3600), - }, - expectError: true, - errorField: "end_pickup_drop_off_window", - }, - { - name: "Invalid: Only end window present", - stopTime: &StopTime{ - EndPickupDropOffWindow: tt.NewSeconds(7200), - }, - expectError: true, - errorField: "start_pickup_drop_off_window", - }, - { - name: "Invalid: End window before start window", - stopTime: &StopTime{ - StartPickupDropOffWindow: tt.NewSeconds(7200), - EndPickupDropOffWindow: tt.NewSeconds(3600), - }, - expectError: true, - errorField: "end_pickup_drop_off_window", - }, - { - name: "Invalid: Time window with arrival_time", - stopTime: &StopTime{ - StartPickupDropOffWindow: tt.NewSeconds(3600), - EndPickupDropOffWindow: tt.NewSeconds(7200), - ArrivalTime: tt.NewSeconds(10800), - }, - expectError: true, - errorField: "arrival_time", - }, - { - name: "Invalid: Time window with departure_time", - stopTime: &StopTime{ - StartPickupDropOffWindow: tt.NewSeconds(3600), - EndPickupDropOffWindow: tt.NewSeconds(7200), - DepartureTime: tt.NewSeconds(10800), - }, - expectError: true, - errorField: "departure_time", - }, - { - name: "Valid: pickup_booking_rule_id with pickup_type=2", - stopTime: &StopTime{ - PickupBookingRuleID: tt.NewKey("rule1"), - PickupType: tt.NewInt(2), - }, - expectError: false, - }, - { - name: "Invalid: pickup_booking_rule_id without pickup_type=2", - stopTime: &StopTime{ - PickupBookingRuleID: tt.NewKey("rule1"), - PickupType: tt.NewInt(0), - }, - expectError: true, - errorField: "pickup_booking_rule_id", - }, - { - name: "Valid: empty pickup_booking_rule_id with pickup_type=2", - stopTime: &StopTime{ - PickupBookingRuleID: tt.NewKey(""), // Empty is valid (no booking required) - PickupType: tt.NewInt(2), - }, - expectError: false, - }, - { - name: "Valid: drop_off_booking_rule_id with drop_off_type=2", - stopTime: &StopTime{ - DropOffBookingRuleID: tt.NewKey("rule1"), - DropOffType: tt.NewInt(2), - }, - expectError: false, - }, - { - name: "Invalid: drop_off_booking_rule_id without drop_off_type=2", - stopTime: &StopTime{ - DropOffBookingRuleID: tt.NewKey("rule1"), - DropOffType: tt.NewInt(0), - }, - expectError: true, - errorField: "drop_off_booking_rule_id", - }, - { - name: "Valid: Both mean_duration fields present", - stopTime: &StopTime{ - MeanDurationFactor: tt.NewFloat(1.5), - MeanDurationOffset: tt.NewFloat(300), - }, - expectError: false, - }, - { - name: "Invalid: Only mean_duration_factor present", - stopTime: &StopTime{ - MeanDurationFactor: tt.NewFloat(1.5), - }, - expectError: true, - errorField: "mean_duration_offset", - }, - { - name: "Invalid: Only mean_duration_offset present", - stopTime: &StopTime{ - MeanDurationOffset: tt.NewFloat(300), - }, - expectError: true, - errorField: "mean_duration_factor", - }, - { - name: "Invalid: mean_duration_factor is negative", - stopTime: &StopTime{ - MeanDurationFactor: tt.NewFloat(-1.5), - MeanDurationOffset: tt.NewFloat(300), - }, - expectError: true, - errorField: "mean_duration_factor", - }, - { - name: "Invalid: safe_duration_factor < mean_duration_factor", - stopTime: &StopTime{ - MeanDurationFactor: tt.NewFloat(2.0), - MeanDurationOffset: tt.NewFloat(300), - SafeDurationFactor: tt.NewFloat(1.5), - SafeDurationOffset: tt.NewFloat(400), - }, - expectError: true, - errorField: "safe_duration_factor", - }, - { - name: "Valid: safe_duration_factor >= mean_duration_factor", - stopTime: &StopTime{ - MeanDurationFactor: tt.NewFloat(1.5), - MeanDurationOffset: tt.NewFloat(300), - SafeDurationFactor: tt.NewFloat(2.0), - SafeDurationOffset: tt.NewFloat(400), - }, - expectError: false, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - errs := tc.stopTime.conditionalErrorsFlex() - - if tc.expectError { - assert.NotEmpty(t, errs, "Expected validation error") - if len(errs) > 0 && tc.errorField != "" { - // Check that at least one error mentions the expected field - found := false - for _, err := range errs { - if err.Error() != "" { - found = true - break - } - } - assert.True(t, found, "Expected error related to field %s", tc.errorField) - } - } else { - assert.Empty(t, errs, "Expected no validation errors") - } - }) - } -} diff --git a/gtfs/stop_time_test.go b/gtfs/stop_time_test.go new file mode 100644 index 000000000..e3a5602ad --- /dev/null +++ b/gtfs/stop_time_test.go @@ -0,0 +1,478 @@ +package gtfs + +import ( + "testing" + + "github.com/interline-io/transitland-lib/tt" +) + +func TestStopTime_Errors(t *testing.T) { + tests := []struct { + name string + stopTime *StopTime + expectedErrors []ExpectedError + }{ + { + name: "Valid: Basic stop time", + stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(1), + ArrivalTime: tt.NewSeconds(3600), + DepartureTime: tt.NewSeconds(3630), + }, + expectedErrors: nil, + }, + { + name: "Invalid: stop_sequence negative", + stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(-1), + ArrivalTime: tt.NewSeconds(3600), + DepartureTime: tt.NewSeconds(3630), + }, + expectedErrors: []ExpectedError{{Field: "stop_sequence", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Invalid: pickup_type out of range", + stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(1), + ArrivalTime: tt.NewSeconds(3600), + DepartureTime: tt.NewSeconds(3630), + PickupType: tt.NewInt(4), + }, + expectedErrors: []ExpectedError{{Field: "pickup_type", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Invalid: drop_off_type out of range", + stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(1), + ArrivalTime: tt.NewSeconds(3600), + DepartureTime: tt.NewSeconds(3630), + DropOffType: tt.NewInt(4), + }, + expectedErrors: []ExpectedError{{Field: "drop_off_type", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Invalid: timepoint out of range", + stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(1), + ArrivalTime: tt.NewSeconds(3600), + DepartureTime: tt.NewSeconds(3630), + Timepoint: tt.NewInt(2), + }, + expectedErrors: []ExpectedError{{Field: "timepoint", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Invalid: departure_time before arrival_time", + stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(1), + ArrivalTime: tt.NewSeconds(3600), + DepartureTime: tt.NewSeconds(1800), + }, + expectedErrors: []ExpectedError{{Field: "departure_time", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Invalid: continuous_pickup out of range", + stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(1), + ArrivalTime: tt.NewSeconds(3660), + DepartureTime: tt.NewSeconds(3690), + ContinuousPickup: tt.NewInt(100), + }, + expectedErrors: []ExpectedError{{Field: "continuous_pickup", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Invalid: continuous_drop_off out of range", + stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(1), + ArrivalTime: tt.NewSeconds(3660), + DepartureTime: tt.NewSeconds(3690), + ContinuousDropOff: tt.NewInt(100), + }, + expectedErrors: []ExpectedError{{Field: "continuous_drop_off", ErrorType: "InvalidFieldError"}}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errs := tc.stopTime.Errors() + checkErrors(t, errs, tc.expectedErrors) + }) + } +} + +func TestStopTime_ConditionalErrorsFlex(t *testing.T) { + tests := []struct { + name string + stopTime *StopTime + expectedErrors []ExpectedError + }{ + // Location field mutual exclusion tests + { + name: "Valid: Only stop_id present", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + }, + expectedErrors: nil, + }, + { + name: "Valid: Only location_group_id present with time windows", + stopTime: &StopTime{ + LocationGroupID: tt.NewKey("lg1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + }, + expectedErrors: nil, + }, + { + name: "Valid: Only location_id present with time windows", + stopTime: &StopTime{ + LocationID: tt.NewKey("loc1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + }, + expectedErrors: nil, + }, + { + name: "Invalid: No location identifier", + stopTime: &StopTime{ + StopSequence: tt.NewInt(1), + }, + expectedErrors: []ExpectedError{{Field: "stop_id", ErrorType: "ConditionallyRequiredFieldError"}}, + }, + { + name: "Invalid: stop_id and location_group_id both present", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + LocationGroupID: tt.NewKey("lg1"), + }, + expectedErrors: []ExpectedError{ + {Field: "location_group_id", ErrorType: "ConditionallyForbiddenFieldError"}, + {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + }, + }, + { + name: "Invalid: stop_id and location_id both present", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + LocationID: tt.NewKey("loc1"), + }, + expectedErrors: []ExpectedError{ + {Field: "location_id", ErrorType: "ConditionallyForbiddenFieldError"}, + {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + }, + }, + { + name: "Invalid: location_group_id and location_id both present", + stopTime: &StopTime{ + LocationGroupID: tt.NewKey("lg1"), + LocationID: tt.NewKey("loc1"), + }, + expectedErrors: []ExpectedError{ + {Field: "location_id", ErrorType: "ConditionallyForbiddenFieldError"}, + {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + }, + }, + { + name: "Invalid: location_group_id without time windows", + stopTime: &StopTime{ + LocationGroupID: tt.NewKey("lg1"), + }, + expectedErrors: []ExpectedError{ + {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + }, + }, + { + name: "Invalid: location_id without time windows", + stopTime: &StopTime{ + LocationID: tt.NewKey("loc1"), + }, + expectedErrors: []ExpectedError{ + {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, + }, + }, + { + name: "Valid: Both time windows present and consistent", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + }, + expectedErrors: nil, + }, + { + name: "Invalid: Only start window present", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + }, + expectedErrors: []ExpectedError{{Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}}, + }, + { + name: "Invalid: Only end window present", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + EndPickupDropOffWindow: tt.NewSeconds(7200), + }, + expectedErrors: []ExpectedError{{Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}}, + }, + { + name: "Invalid: End window before start window", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(7200), + EndPickupDropOffWindow: tt.NewSeconds(3600), + }, + expectedErrors: []ExpectedError{{Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}}, + }, + { + name: "Invalid: Time window with arrival_time", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + ArrivalTime: tt.NewSeconds(10800), + }, + expectedErrors: []ExpectedError{{Field: "arrival_time", ErrorType: "ConditionallyForbiddenFieldError"}}, + }, + { + name: "Invalid: Time window with departure_time", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + DepartureTime: tt.NewSeconds(10800), + }, + expectedErrors: []ExpectedError{{Field: "departure_time", ErrorType: "ConditionallyForbiddenFieldError"}}, + }, + // pickup_type and drop_off_type with time windows + { + name: "Invalid: pickup_type=0 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + PickupType: tt.NewInt(0), + }, + expectedErrors: []ExpectedError{{Field: "pickup_type", ErrorType: "ConditionallyForbiddenFieldError"}}, + }, + { + name: "Invalid: pickup_type=3 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + PickupType: tt.NewInt(3), + }, + expectedErrors: []ExpectedError{{Field: "pickup_type", ErrorType: "ConditionallyForbiddenFieldError"}}, + }, + { + name: "Valid: pickup_type=2 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + PickupType: tt.NewInt(2), + }, + expectedErrors: nil, + }, + { + name: "Invalid: drop_off_type=0 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + DropOffType: tt.NewInt(0), + }, + expectedErrors: []ExpectedError{{Field: "drop_off_type", ErrorType: "ConditionallyForbiddenFieldError"}}, + }, + { + name: "Valid: drop_off_type=2 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + DropOffType: tt.NewInt(2), + }, + expectedErrors: nil, + }, + // continuous_pickup and continuous_drop_off with time windows + { + name: "Invalid: continuous_pickup=0 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + ContinuousPickup: tt.NewInt(0), + }, + expectedErrors: []ExpectedError{{Field: "continuous_pickup", ErrorType: "ConditionallyForbiddenFieldError"}}, + }, + { + name: "Invalid: continuous_pickup=2 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + ContinuousPickup: tt.NewInt(2), + }, + expectedErrors: []ExpectedError{{Field: "continuous_pickup", ErrorType: "ConditionallyForbiddenFieldError"}}, + }, + { + name: "Valid: continuous_pickup=1 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + ContinuousPickup: tt.NewInt(1), + }, + expectedErrors: nil, + }, + { + name: "Invalid: continuous_drop_off=0 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + ContinuousDropOff: tt.NewInt(0), + }, + expectedErrors: []ExpectedError{{Field: "continuous_drop_off", ErrorType: "ConditionallyForbiddenFieldError"}}, + }, + { + name: "Valid: continuous_drop_off=1 with time windows", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + StartPickupDropOffWindow: tt.NewSeconds(3600), + EndPickupDropOffWindow: tt.NewSeconds(7200), + ContinuousDropOff: tt.NewInt(1), + }, + expectedErrors: nil, + }, + { + name: "Valid: pickup_booking_rule_id with pickup_type=2", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + PickupBookingRuleID: tt.NewKey("rule1"), + PickupType: tt.NewInt(2), + }, + expectedErrors: nil, + }, + { + name: "Invalid: pickup_booking_rule_id without pickup_type=2", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + PickupBookingRuleID: tt.NewKey("rule1"), + PickupType: tt.NewInt(0), + }, + expectedErrors: []ExpectedError{{Field: "pickup_booking_rule_id", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Valid: empty pickup_booking_rule_id with pickup_type=2", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + PickupBookingRuleID: tt.NewKey(""), // Empty is valid (no booking required) + PickupType: tt.NewInt(2), + }, + expectedErrors: nil, + }, + { + name: "Valid: drop_off_booking_rule_id with drop_off_type=2", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + DropOffBookingRuleID: tt.NewKey("rule1"), + DropOffType: tt.NewInt(2), + }, + expectedErrors: nil, + }, + { + name: "Invalid: drop_off_booking_rule_id without drop_off_type=2", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + DropOffBookingRuleID: tt.NewKey("rule1"), + DropOffType: tt.NewInt(0), + }, + expectedErrors: []ExpectedError{{Field: "drop_off_booking_rule_id", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Valid: Both mean_duration fields present", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + MeanDurationFactor: tt.NewFloat(1.5), + MeanDurationOffset: tt.NewFloat(300), + }, + expectedErrors: nil, + }, + { + name: "Invalid: Only mean_duration_factor present", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + MeanDurationFactor: tt.NewFloat(1.5), + }, + expectedErrors: []ExpectedError{{Field: "mean_duration_offset", ErrorType: "ConditionallyRequiredFieldError"}}, + }, + { + name: "Invalid: Only mean_duration_offset present", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + MeanDurationOffset: tt.NewFloat(300), + }, + expectedErrors: []ExpectedError{{Field: "mean_duration_factor", ErrorType: "ConditionallyRequiredFieldError"}}, + }, + { + name: "Invalid: mean_duration_factor is negative", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + MeanDurationFactor: tt.NewFloat(-1.5), + MeanDurationOffset: tt.NewFloat(300), + }, + expectedErrors: []ExpectedError{{Field: "mean_duration_factor", ErrorType: "ConditionallyRequiredFieldError"}}, + }, + { + name: "Invalid: safe_duration_factor < mean_duration_factor", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + MeanDurationFactor: tt.NewFloat(2.0), + MeanDurationOffset: tt.NewFloat(300), + SafeDurationFactor: tt.NewFloat(1.5), + SafeDurationOffset: tt.NewFloat(400), + }, + expectedErrors: []ExpectedError{{Field: "safe_duration_factor", ErrorType: "InvalidFieldError"}}, + }, + { + name: "Valid: safe_duration_factor >= mean_duration_factor", + stopTime: &StopTime{ + StopID: tt.NewKey("stop1"), + MeanDurationFactor: tt.NewFloat(1.5), + MeanDurationOffset: tt.NewFloat(300), + SafeDurationFactor: tt.NewFloat(2.0), + SafeDurationOffset: tt.NewFloat(400), + }, + expectedErrors: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errs := tc.stopTime.ConditionalErrors() + checkErrors(t, errs, tc.expectedErrors) + }) + } +} diff --git a/internal/testutil/minimal.go b/internal/testutil/minimal.go index 5e363cbeb..8bf24aed9 100644 --- a/internal/testutil/minimal.go +++ b/internal/testutil/minimal.go @@ -25,8 +25,8 @@ func NewMinimalTestFeed() (*ReaderTester, *direct.Reader) { {StopID: tt.NewString("stop2"), StopName: tt.NewString("Stop 2"), Geometry: tt.NewPoint(3, 4)}, }, StopTimeList: []gtfs.StopTime{ - {StopID: tt.NewString("stop1"), TripID: tt.NewString("trip1"), StopSequence: tt.NewInt(1), ArrivalTime: tt.NewSeconds(0), DepartureTime: tt.NewSeconds(5)}, - {StopID: tt.NewString("stop2"), TripID: tt.NewString("trip1"), StopSequence: tt.NewInt(2), ArrivalTime: tt.NewSeconds(10), DepartureTime: tt.NewSeconds(15)}, + {StopID: tt.NewKey("stop1"), TripID: tt.NewString("trip1"), StopSequence: tt.NewInt(1), ArrivalTime: tt.NewSeconds(0), DepartureTime: tt.NewSeconds(5)}, + {StopID: tt.NewKey("stop2"), TripID: tt.NewString("trip1"), StopSequence: tt.NewInt(2), ArrivalTime: tt.NewSeconds(10), DepartureTime: tt.NewSeconds(15)}, }, ShapeList: []gtfs.Shape{ {ShapeID: tt.NewString("shape1"), ShapePtLon: tt.NewFloat(1), ShapePtLat: tt.NewFloat(2), ShapePtSequence: tt.NewInt(0)}, diff --git a/rules/flex_validation_test.go b/rules/flex_validation_test.go index 466d4cdd8..ffd631f77 100644 --- a/rules/flex_validation_test.go +++ b/rules/flex_validation_test.go @@ -25,7 +25,7 @@ func TestFlexStopLocationTypeCheck(t *testing.T) { LocationType: tt.NewInt(0), }, stopTime: >fs.StopTime{ - StopID: tt.NewString("stop1"), + StopID: tt.NewKey("stop1"), PickupType: tt.NewInt(2), // flex service }, expectError: false, @@ -37,7 +37,7 @@ func TestFlexStopLocationTypeCheck(t *testing.T) { LocationType: tt.NewInt(1), }, stopTime: >fs.StopTime{ - StopID: tt.NewString("stop2"), + StopID: tt.NewKey("stop2"), PickupType: tt.NewInt(2), }, expectError: true, @@ -49,7 +49,7 @@ func TestFlexStopLocationTypeCheck(t *testing.T) { LocationType: tt.NewInt(1), }, stopTime: >fs.StopTime{ - StopID: tt.NewString("stop3"), + StopID: tt.NewKey("stop3"), PickupType: tt.NewInt(0), // regular service }, expectError: false, diff --git a/rules/stop_time_sequence_test.go b/rules/stop_time_sequence_test.go index 80e95f364..dec143306 100644 --- a/rules/stop_time_sequence_test.go +++ b/rules/stop_time_sequence_test.go @@ -20,7 +20,7 @@ func expectTripToStopTime(e expectTrip) []gtfs.StopTime { for i := range e.ArrivalTime { ret = append(ret, gtfs.StopTime{ TripID: tt.NewString("1"), - StopID: tt.NewString(strconv.Itoa(i)), + StopID: tt.NewKey(strconv.Itoa(i)), StopSequence: tt.NewInt(i), ArrivalTime: tt.NewSeconds(e.ArrivalTime[i]), DepartureTime: tt.NewSeconds(e.DepartureTime[i]), diff --git a/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql b/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql index cf12cb704..2d256e2f5 100644 --- a/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql +++ b/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql @@ -79,15 +79,21 @@ CREATE INDEX ON gtfs_locations USING GIST(geometry); -- GTFS-Flex: Add new fields to stop_times ALTER TABLE public.gtfs_stop_times + ADD COLUMN location_group_id bigint REFERENCES gtfs_location_groups(id), + ADD COLUMN location_id bigint REFERENCES gtfs_locations(id), ADD COLUMN start_pickup_drop_off_window integer, ADD COLUMN end_pickup_drop_off_window integer, - ADD COLUMN pickup_booking_rule_id bigint, - ADD COLUMN drop_off_booking_rule_id bigint, + ADD COLUMN pickup_booking_rule_id bigint REFERENCES gtfs_booking_rules(id), + ADD COLUMN drop_off_booking_rule_id bigint REFERENCES gtfs_booking_rules(id), ADD COLUMN mean_duration_factor double precision, ADD COLUMN mean_duration_offset double precision, ADD COLUMN safe_duration_factor double precision, ADD COLUMN safe_duration_offset double precision; +-- GTFS-Flex: Drop not-null constraint on stop_id (now conditionally required) +ALTER TABLE public.gtfs_stop_times + ALTER COLUMN stop_id DROP NOT NULL; + COMMIT; diff --git a/schema/sqlite/sqlite.sql b/schema/sqlite/sqlite.sql index 97e1b45b5..d8f26e477 100644 --- a/schema/sqlite/sqlite.sql +++ b/schema/sqlite/sqlite.sql @@ -400,6 +400,8 @@ CREATE TABLE IF NOT EXISTS "gtfs_stop_times" ( "interpolated" integer, "feed_version_id" integer NOT NULL, -- GTFS-Flex fields + "location_group_id" integer, + "location_id" integer, "start_pickup_drop_off_window" integer, "end_pickup_drop_off_window" integer, "pickup_booking_rule_id" integer, @@ -411,6 +413,8 @@ CREATE TABLE IF NOT EXISTS "gtfs_stop_times" ( foreign key(feed_version_id) REFERENCES feed_versions(id), foreign key(trip_id) references gtfs_trips(id), foreign key(stop_id) references gtfs_stops(id), + foreign key(location_group_id) references gtfs_location_groups(id), + foreign key(location_id) references gtfs_locations(id), foreign key(pickup_booking_rule_id) references gtfs_booking_rules(id), foreign key(drop_off_booking_rule_id) references gtfs_booking_rules(id) ); diff --git a/testdata/gtfs-examples/bad-entities/booking_rules.txt b/testdata/gtfs-examples/bad-entities/booking_rules.txt new file mode 100644 index 000000000..8f555e532 --- /dev/null +++ b/testdata/gtfs-examples/bad-entities/booking_rules.txt @@ -0,0 +1,24 @@ +booking_rule_id,booking_type,prior_notice_duration_min,prior_notice_duration_max,prior_notice_last_day,prior_notice_last_time,prior_notice_start_day,prior_notice_start_time,prior_notice_service_id,message,pickup_message,drop_off_message,phone_number,info_url,booking_url,expect_error,expect_error,comment +ok_type0,0,,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,,Valid booking rule type 0 (real-time) +ok_type1,1,,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,,Valid booking rule type 1 (up to same day) +ok_type2,2,30,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,,Valid booking rule type 2 (advance notice) +missing_id,,0,,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,RequiredFieldError:booking_rule_id,booking_rule_id is required +parse_booking_type,ok1,xyz,,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,FieldParseError:booking_type,Non-integer booking_type value +invalid_booking_type,ok1,3,,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,InvalidFieldError:booking_type,booking_type must be 0-2 +parse_prior_notice_min,ok1,2,xyz,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,FieldParseError:prior_notice_duration_min,Non-integer prior_notice_duration_min value +parse_prior_notice_max,ok1,2,30,xyz,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,FieldParseError:prior_notice_duration_max,Non-integer prior_notice_duration_max value +parse_prior_notice_last_day,ok1,2,30,,xyz,,,,,,,,555-1234,https://example.com/info,https://example.com/book,FieldParseError:prior_notice_last_day,Non-integer prior_notice_last_day value +parse_prior_notice_start_day,ok1,2,30,,,,,xyz,,,,,555-1234,https://example.com/info,https://example.com/book,FieldParseError:prior_notice_start_day,Non-integer prior_notice_start_day value +type2_missing_min,ok2,2,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyRequiredFieldError:prior_notice_duration_min,booking_type=2 requires prior_notice_duration_min +type2_forbidden_max,ok2,2,30,60,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyForbiddenFieldError:prior_notice_duration_max,booking_type=2 forbids prior_notice_duration_max +type2_forbidden_last_day,ok2,2,30,,1,,,,,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyForbiddenFieldError:prior_notice_last_day,booking_type=2 forbids prior_notice_last_day +type2_forbidden_last_time,ok2,2,30,,,12:00:00,,,,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyForbiddenFieldError:prior_notice_last_time,booking_type=2 forbids prior_notice_last_time +type2_forbidden_start_day,ok2,2,30,,,,,1,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyForbiddenFieldError:prior_notice_start_day,booking_type=2 forbids prior_notice_start_day +type2_forbidden_start_time,ok2,2,30,,,,,,08:00:00,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyForbiddenFieldError:prior_notice_start_time,booking_type=2 forbids prior_notice_start_time +type2_forbidden_service_id,ok2,2,30,,,,,,,svc1,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyForbiddenFieldError:prior_notice_service_id,booking_type=2 forbids prior_notice_service_id +type01_forbidden_min,ok1,1,30,,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyForbiddenFieldError:prior_notice_duration_min,booking_type=0/1 forbids prior_notice_duration_min +last_time_without_day,ok1,0,,,,12:00:00,,,,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyRequiredFieldError:prior_notice_last_day,prior_notice_last_time requires prior_notice_last_day +last_day_without_time,ok1,0,,,1,,,,,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyRequiredFieldError:prior_notice_last_time,prior_notice_last_day requires prior_notice_last_time +start_time_without_day,ok1,0,,,,,,,08:00:00,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyRequiredFieldError:prior_notice_start_day,prior_notice_start_time requires prior_notice_start_day +start_day_without_time,ok1,0,,,,,,1,,,,,555-1234,https://example.com/info,https://example.com/book,ConditionallyRequiredFieldError:prior_notice_start_time,prior_notice_start_day requires prior_notice_start_time +max_less_than_min,ok1,0,30,15,,,,,,,,,555-1234,https://example.com/info,https://example.com/book,InvalidFieldError:prior_notice_duration_max,prior_notice_duration_max must be >= prior_notice_duration_min diff --git a/testdata/gtfs-examples/bad-entities/location_group_stops.txt b/testdata/gtfs-examples/bad-entities/location_group_stops.txt new file mode 100644 index 000000000..2ada82d29 --- /dev/null +++ b/testdata/gtfs-examples/bad-entities/location_group_stops.txt @@ -0,0 +1,4 @@ +location_group_id,stop_id,expect_error,expect_error,comment +lg1,stop1,,Valid location group stop association +missing_location_group,,stop1,RequiredFieldError:location_group_id,location_group_id is required +missing_stop,lg1,,RequiredFieldError:stop_id,stop_id is required diff --git a/testdata/gtfs-examples/bad-entities/location_groups.txt b/testdata/gtfs-examples/bad-entities/location_groups.txt new file mode 100644 index 000000000..3c5cb02bc --- /dev/null +++ b/testdata/gtfs-examples/bad-entities/location_groups.txt @@ -0,0 +1,3 @@ +location_group_id,location_group_name,expect_error,expect_error,comment +ok,Downtown Zone,,Valid location group +missing_id,,Valid Zone Name,RequiredFieldError:location_group_id,location_group_id is required diff --git a/testdata/gtfs-examples/bad-entities/locations.txt b/testdata/gtfs-examples/bad-entities/locations.txt new file mode 100644 index 000000000..5ff9ead38 --- /dev/null +++ b/testdata/gtfs-examples/bad-entities/locations.txt @@ -0,0 +1,5 @@ +location_id,location_name,location_lat,location_lon,expect_error,expect_error,comment +loc1,Flex Zone A,37.7749,-122.4194,,Valid location with coordinates +missing_id,,Valid Zone Name,37.7749,-122.4194,RequiredFieldError:location_id,location_id is required +parse_lat,loc2,Valid Zone,xyz,-122.4194,FieldParseError:location_lat,Non-numeric location_lat value +parse_lon,loc3,Valid Zone,37.7749,xyz,FieldParseError:location_lon,Non-numeric location_lon value diff --git a/tlcsv/reflect_benchmark_test.go b/tlcsv/reflect_benchmark_test.go index cd735dc44..d3cba0ad4 100644 --- a/tlcsv/reflect_benchmark_test.go +++ b/tlcsv/reflect_benchmark_test.go @@ -110,7 +110,7 @@ func Benchmark_loadRow_Trip(b *testing.B) { func Benchmark_dumpRow_StopTime(b *testing.B) { ent := gtfs.StopTime{ TripID: tt.NewString("xyz"), - StopID: tt.NewString("abc"), + StopID: tt.NewKey("abc"), StopHeadsign: tt.NewString("hello"), StopSequence: tt.NewInt(123), ArrivalTime: tt.NewSeconds(3600), diff --git a/tlcsv/reflect_test.go b/tlcsv/reflect_test.go index 87b992c17..b310e11ea 100644 --- a/tlcsv/reflect_test.go +++ b/tlcsv/reflect_test.go @@ -11,7 +11,7 @@ import ( func TestGetString(t *testing.T) { ent := gtfs.StopTime{ TripID: tt.NewString("123"), - StopID: tt.NewString("456"), + StopID: tt.NewKey("456"), ArrivalTime: tt.NewSeconds(3600), DepartureTime: tt.NewSeconds(7200), ShapeDistTraveled: tt.NewFloat(123.456), From 2c0858869f3d7f922065c58ec486141690b56d26 Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 03:10:53 -0800 Subject: [PATCH 08/17] Test helpers --- gtfs/gtfs_test.go | 21 +++++++++ gtfs/stop_time_test.go | 96 +++++++++++++++++++++--------------------- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/gtfs/gtfs_test.go b/gtfs/gtfs_test.go index f1e543d13..5c712a415 100644 --- a/gtfs/gtfs_test.go +++ b/gtfs/gtfs_test.go @@ -16,6 +16,27 @@ import ( type ExpectedError struct { Field string // The field name that should be mentioned in the error (required) ErrorType string // The error type (required: FieldParseError, InvalidFieldError, ConditionallyRequiredFieldError, ConditionallyForbiddenFieldError) + Filename string // The filename where the error occurred (optional) + EntityID string // The entity ID where the error occurred (optional) +} + +// expectErrors converts string tuples into ExpectedError structs. +// Each string should be in the format " ". +// Filename and entity_id are optional and can be empty strings. +// Example: expectErrors("InvalidFieldError stop_sequence", "ConditionallyRequiredFieldError stop_id stops.txt stop1") +func expectErrors(errorStrings ...string) []ExpectedError { + var errors []ExpectedError + for _, s := range errorStrings { + var errType, field, filename, entityID string + fmt.Sscanf(s, "%s %s %s %s", &errType, &field, &filename, &entityID) + errors = append(errors, ExpectedError{ + ErrorType: errType, + Field: field, + Filename: filename, + EntityID: entityID, + }) + } + return errors } // checkErrors is a helper function to validate errors (both conditional and non-conditional) diff --git a/gtfs/stop_time_test.go b/gtfs/stop_time_test.go index e3a5602ad..0c290d19d 100644 --- a/gtfs/stop_time_test.go +++ b/gtfs/stop_time_test.go @@ -32,7 +32,7 @@ func TestStopTime_Errors(t *testing.T) { ArrivalTime: tt.NewSeconds(3600), DepartureTime: tt.NewSeconds(3630), }, - expectedErrors: []ExpectedError{{Field: "stop_sequence", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError stop_sequence"), }, { name: "Invalid: pickup_type out of range", @@ -44,7 +44,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3630), PickupType: tt.NewInt(4), }, - expectedErrors: []ExpectedError{{Field: "pickup_type", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError pickup_type"), }, { name: "Invalid: drop_off_type out of range", @@ -56,7 +56,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3630), DropOffType: tt.NewInt(4), }, - expectedErrors: []ExpectedError{{Field: "drop_off_type", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError drop_off_type"), }, { name: "Invalid: timepoint out of range", @@ -68,7 +68,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3630), Timepoint: tt.NewInt(2), }, - expectedErrors: []ExpectedError{{Field: "timepoint", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError timepoint"), }, { name: "Invalid: departure_time before arrival_time", @@ -79,7 +79,7 @@ func TestStopTime_Errors(t *testing.T) { ArrivalTime: tt.NewSeconds(3600), DepartureTime: tt.NewSeconds(1800), }, - expectedErrors: []ExpectedError{{Field: "departure_time", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError departure_time"), }, { name: "Invalid: continuous_pickup out of range", @@ -91,7 +91,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3690), ContinuousPickup: tt.NewInt(100), }, - expectedErrors: []ExpectedError{{Field: "continuous_pickup", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError continuous_pickup"), }, { name: "Invalid: continuous_drop_off out of range", @@ -103,7 +103,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3690), ContinuousDropOff: tt.NewInt(100), }, - expectedErrors: []ExpectedError{{Field: "continuous_drop_off", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError continuous_drop_off"), }, } @@ -152,7 +152,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { stopTime: &StopTime{ StopSequence: tt.NewInt(1), }, - expectedErrors: []ExpectedError{{Field: "stop_id", ErrorType: "ConditionallyRequiredFieldError"}}, + expectedErrors: expectErrors("ConditionallyRequiredFieldError stop_id"), }, { name: "Invalid: stop_id and location_group_id both present", @@ -160,11 +160,11 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), LocationGroupID: tt.NewKey("lg1"), }, - expectedErrors: []ExpectedError{ - {Field: "location_group_id", ErrorType: "ConditionallyForbiddenFieldError"}, - {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - }, + expectedErrors: expectErrors( + "ConditionallyForbiddenFieldError location_group_id", + "ConditionallyRequiredFieldError start_pickup_drop_off_window", + "ConditionallyRequiredFieldError end_pickup_drop_off_window", + ), }, { name: "Invalid: stop_id and location_id both present", @@ -172,11 +172,11 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), LocationID: tt.NewKey("loc1"), }, - expectedErrors: []ExpectedError{ - {Field: "location_id", ErrorType: "ConditionallyForbiddenFieldError"}, - {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - }, + expectedErrors: expectErrors( + "ConditionallyForbiddenFieldError location_id", + "ConditionallyRequiredFieldError start_pickup_drop_off_window", + "ConditionallyRequiredFieldError end_pickup_drop_off_window", + ), }, { name: "Invalid: location_group_id and location_id both present", @@ -184,31 +184,31 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { LocationGroupID: tt.NewKey("lg1"), LocationID: tt.NewKey("loc1"), }, - expectedErrors: []ExpectedError{ - {Field: "location_id", ErrorType: "ConditionallyForbiddenFieldError"}, - {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - }, + expectedErrors: expectErrors( + "ConditionallyForbiddenFieldError location_id", + "ConditionallyRequiredFieldError start_pickup_drop_off_window", + "ConditionallyRequiredFieldError end_pickup_drop_off_window", + ), }, { name: "Invalid: location_group_id without time windows", stopTime: &StopTime{ LocationGroupID: tt.NewKey("lg1"), }, - expectedErrors: []ExpectedError{ - {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - }, + expectedErrors: expectErrors( + "ConditionallyRequiredFieldError start_pickup_drop_off_window", + "ConditionallyRequiredFieldError end_pickup_drop_off_window", + ), }, { name: "Invalid: location_id without time windows", stopTime: &StopTime{ LocationID: tt.NewKey("loc1"), }, - expectedErrors: []ExpectedError{ - {Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - {Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}, - }, + expectedErrors: expectErrors( + "ConditionallyRequiredFieldError start_pickup_drop_off_window", + "ConditionallyRequiredFieldError end_pickup_drop_off_window", + ), }, { name: "Valid: Both time windows present and consistent", @@ -225,7 +225,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), }, - expectedErrors: []ExpectedError{{Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}}, + expectedErrors: expectErrors("ConditionallyRequiredFieldError end_pickup_drop_off_window"), }, { name: "Invalid: Only end window present", @@ -233,7 +233,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), EndPickupDropOffWindow: tt.NewSeconds(7200), }, - expectedErrors: []ExpectedError{{Field: "start_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}}, + expectedErrors: expectErrors("ConditionallyRequiredFieldError start_pickup_drop_off_window"), }, { name: "Invalid: End window before start window", @@ -242,7 +242,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StartPickupDropOffWindow: tt.NewSeconds(7200), EndPickupDropOffWindow: tt.NewSeconds(3600), }, - expectedErrors: []ExpectedError{{Field: "end_pickup_drop_off_window", ErrorType: "ConditionallyRequiredFieldError"}}, + expectedErrors: expectErrors("ConditionallyRequiredFieldError end_pickup_drop_off_window"), }, { name: "Invalid: Time window with arrival_time", @@ -252,7 +252,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), ArrivalTime: tt.NewSeconds(10800), }, - expectedErrors: []ExpectedError{{Field: "arrival_time", ErrorType: "ConditionallyForbiddenFieldError"}}, + expectedErrors: expectErrors("ConditionallyForbiddenFieldError arrival_time"), }, { name: "Invalid: Time window with departure_time", @@ -262,7 +262,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), DepartureTime: tt.NewSeconds(10800), }, - expectedErrors: []ExpectedError{{Field: "departure_time", ErrorType: "ConditionallyForbiddenFieldError"}}, + expectedErrors: expectErrors("ConditionallyForbiddenFieldError departure_time"), }, // pickup_type and drop_off_type with time windows { @@ -273,7 +273,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), PickupType: tt.NewInt(0), }, - expectedErrors: []ExpectedError{{Field: "pickup_type", ErrorType: "ConditionallyForbiddenFieldError"}}, + expectedErrors: expectErrors("ConditionallyForbiddenFieldError pickup_type"), }, { name: "Invalid: pickup_type=3 with time windows", @@ -283,7 +283,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), PickupType: tt.NewInt(3), }, - expectedErrors: []ExpectedError{{Field: "pickup_type", ErrorType: "ConditionallyForbiddenFieldError"}}, + expectedErrors: expectErrors("ConditionallyForbiddenFieldError pickup_type"), }, { name: "Valid: pickup_type=2 with time windows", @@ -303,7 +303,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), DropOffType: tt.NewInt(0), }, - expectedErrors: []ExpectedError{{Field: "drop_off_type", ErrorType: "ConditionallyForbiddenFieldError"}}, + expectedErrors: expectErrors("ConditionallyForbiddenFieldError drop_off_type"), }, { name: "Valid: drop_off_type=2 with time windows", @@ -324,7 +324,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), ContinuousPickup: tt.NewInt(0), }, - expectedErrors: []ExpectedError{{Field: "continuous_pickup", ErrorType: "ConditionallyForbiddenFieldError"}}, + expectedErrors: expectErrors("ConditionallyForbiddenFieldError continuous_pickup"), }, { name: "Invalid: continuous_pickup=2 with time windows", @@ -334,7 +334,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), ContinuousPickup: tt.NewInt(2), }, - expectedErrors: []ExpectedError{{Field: "continuous_pickup", ErrorType: "ConditionallyForbiddenFieldError"}}, + expectedErrors: expectErrors("ConditionallyForbiddenFieldError continuous_pickup"), }, { name: "Valid: continuous_pickup=1 with time windows", @@ -354,7 +354,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), ContinuousDropOff: tt.NewInt(0), }, - expectedErrors: []ExpectedError{{Field: "continuous_drop_off", ErrorType: "ConditionallyForbiddenFieldError"}}, + expectedErrors: expectErrors("ConditionallyForbiddenFieldError continuous_drop_off"), }, { name: "Valid: continuous_drop_off=1 with time windows", @@ -382,7 +382,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { PickupBookingRuleID: tt.NewKey("rule1"), PickupType: tt.NewInt(0), }, - expectedErrors: []ExpectedError{{Field: "pickup_booking_rule_id", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError pickup_booking_rule_id"), }, { name: "Valid: empty pickup_booking_rule_id with pickup_type=2", @@ -409,7 +409,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { DropOffBookingRuleID: tt.NewKey("rule1"), DropOffType: tt.NewInt(0), }, - expectedErrors: []ExpectedError{{Field: "drop_off_booking_rule_id", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError drop_off_booking_rule_id"), }, { name: "Valid: Both mean_duration fields present", @@ -426,7 +426,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), MeanDurationFactor: tt.NewFloat(1.5), }, - expectedErrors: []ExpectedError{{Field: "mean_duration_offset", ErrorType: "ConditionallyRequiredFieldError"}}, + expectedErrors: expectErrors("ConditionallyRequiredFieldError mean_duration_offset"), }, { name: "Invalid: Only mean_duration_offset present", @@ -434,7 +434,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), MeanDurationOffset: tt.NewFloat(300), }, - expectedErrors: []ExpectedError{{Field: "mean_duration_factor", ErrorType: "ConditionallyRequiredFieldError"}}, + expectedErrors: expectErrors("ConditionallyRequiredFieldError mean_duration_factor"), }, { name: "Invalid: mean_duration_factor is negative", @@ -443,7 +443,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { MeanDurationFactor: tt.NewFloat(-1.5), MeanDurationOffset: tt.NewFloat(300), }, - expectedErrors: []ExpectedError{{Field: "mean_duration_factor", ErrorType: "ConditionallyRequiredFieldError"}}, + expectedErrors: expectErrors("ConditionallyRequiredFieldError mean_duration_factor"), }, { name: "Invalid: safe_duration_factor < mean_duration_factor", @@ -454,7 +454,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { SafeDurationFactor: tt.NewFloat(1.5), SafeDurationOffset: tt.NewFloat(400), }, - expectedErrors: []ExpectedError{{Field: "safe_duration_factor", ErrorType: "InvalidFieldError"}}, + expectedErrors: expectErrors("InvalidFieldError safe_duration_factor"), }, { name: "Valid: safe_duration_factor >= mean_duration_factor", From 9b5cd495d24d1c9424017df047ee6ece8005613f Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 03:41:08 -0800 Subject: [PATCH 09/17] More testing --- gtfs/stop_time.go | 36 +-------------- gtfs/stop_time_test.go | 28 ++--------- rules/stop_time_sequence.go | 64 ++++++++++++++++++++------ testdata/gtfs-external/ctran-flex.zip | Bin 0 -> 89592 bytes 4 files changed, 55 insertions(+), 73 deletions(-) create mode 100644 testdata/gtfs-external/ctran-flex.zip diff --git a/gtfs/stop_time.go b/gtfs/stop_time.go index 6b1bbe851..ad0cbeab1 100644 --- a/gtfs/stop_time.go +++ b/gtfs/stop_time.go @@ -216,30 +216,7 @@ func (ent *StopTime) ConditionalErrors() (errs []error) { } } - // 10. Validate booking rules are only used with appropriate pickup/drop_off types - // pickup_booking_rule_id is recommended when pickup_type = 2 - if ent.PickupBookingRuleID.IsPresent() { - if !ent.PickupType.Valid || ent.PickupType.Val != 2 { - errs = append(errs, causes.NewInvalidFieldError( - "pickup_booking_rule_id", - ent.PickupBookingRuleID.Val, - fmt.Errorf("pickup_booking_rule_id should only be used when pickup_type = 2 (must phone agency)"), - )) - } - } - - // drop_off_booking_rule_id is recommended when drop_off_type = 2 - if ent.DropOffBookingRuleID.IsPresent() { - if !ent.DropOffType.Valid || ent.DropOffType.Val != 2 { - errs = append(errs, causes.NewInvalidFieldError( - "drop_off_booking_rule_id", - ent.DropOffBookingRuleID.Val, - fmt.Errorf("drop_off_booking_rule_id should only be used when drop_off_type = 2 (must phone agency)"), - )) - } - } - - // 11. Validate mean/safe duration factor/offset + // 10. Validate mean/safe duration factor/offset // mean_duration_factor and mean_duration_offset work together if ent.MeanDurationFactor.Valid && !ent.MeanDurationOffset.Valid { errs = append(errs, causes.NewConditionallyRequiredFieldError("mean_duration_offset")) @@ -265,17 +242,6 @@ func (ent *StopTime) ConditionalErrors() (errs []error) { )) } - // safe_duration_factor should be >= mean_duration_factor if both present - if ent.MeanDurationFactor.Valid && ent.SafeDurationFactor.Valid { - if ent.SafeDurationFactor.Val < ent.MeanDurationFactor.Val { - errs = append(errs, causes.NewInvalidFieldError( - "safe_duration_factor", - fmt.Sprintf("%f", ent.SafeDurationFactor.Val), - fmt.Errorf("must be greater than or equal to mean_duration_factor (%f)", ent.MeanDurationFactor.Val), - )) - } - } - return errs } diff --git a/gtfs/stop_time_test.go b/gtfs/stop_time_test.go index 0c290d19d..9c5af785c 100644 --- a/gtfs/stop_time_test.go +++ b/gtfs/stop_time_test.go @@ -376,21 +376,12 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { expectedErrors: nil, }, { - name: "Invalid: pickup_booking_rule_id without pickup_type=2", + name: "Valid: pickup_booking_rule_id with pickup_type=0", stopTime: &StopTime{ StopID: tt.NewKey("stop1"), PickupBookingRuleID: tt.NewKey("rule1"), PickupType: tt.NewInt(0), }, - expectedErrors: expectErrors("InvalidFieldError pickup_booking_rule_id"), - }, - { - name: "Valid: empty pickup_booking_rule_id with pickup_type=2", - stopTime: &StopTime{ - StopID: tt.NewKey("stop1"), - PickupBookingRuleID: tt.NewKey(""), // Empty is valid (no booking required) - PickupType: tt.NewInt(2), - }, expectedErrors: nil, }, { @@ -403,13 +394,13 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { expectedErrors: nil, }, { - name: "Invalid: drop_off_booking_rule_id without drop_off_type=2", + name: "Valid: drop_off_booking_rule_id with drop_off_type=0", stopTime: &StopTime{ StopID: tt.NewKey("stop1"), DropOffBookingRuleID: tt.NewKey("rule1"), DropOffType: tt.NewInt(0), }, - expectedErrors: expectErrors("InvalidFieldError drop_off_booking_rule_id"), + expectedErrors: nil, }, { name: "Valid: Both mean_duration fields present", @@ -446,18 +437,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { expectedErrors: expectErrors("ConditionallyRequiredFieldError mean_duration_factor"), }, { - name: "Invalid: safe_duration_factor < mean_duration_factor", - stopTime: &StopTime{ - StopID: tt.NewKey("stop1"), - MeanDurationFactor: tt.NewFloat(2.0), - MeanDurationOffset: tt.NewFloat(300), - SafeDurationFactor: tt.NewFloat(1.5), - SafeDurationOffset: tt.NewFloat(400), - }, - expectedErrors: expectErrors("InvalidFieldError safe_duration_factor"), - }, - { - name: "Valid: safe_duration_factor >= mean_duration_factor", + name: "Valid: safe_duration_factor with mean_duration_factor", stopTime: &StopTime{ StopID: tt.NewKey("stop1"), MeanDurationFactor: tt.NewFloat(1.5), diff --git a/rules/stop_time_sequence.go b/rules/stop_time_sequence.go index cd047aca4..4954de94a 100644 --- a/rules/stop_time_sequence.go +++ b/rules/stop_time_sequence.go @@ -21,9 +21,18 @@ func (e *StopTimeSequenceCheck) Validate(ent tt.Entity) []error { return errs } -// ValidateStopTimes checks if the trip follows GTFS rules. +// hasTimeWindow returns true if the stop_time has GTFS-Flex time windows defined. +// Time windows are mutually exclusive with arrival_time/departure_time. +func hasTimeWindow(st gtfs.StopTime) bool { + return (st.StartPickupDropOffWindow.Valid && st.StartPickupDropOffWindow.Val > 0) || + (st.EndPickupDropOffWindow.Valid && st.EndPickupDropOffWindow.Val > 0) +} + +// ValidateStopTimes checks if the trip follows GTFS rules, including GTFS-Flex extensions. func ValidateStopTimes(stoptimes []gtfs.StopTime) []error { errs := []error{} + + // 1. Check has >= 2 stop_times if len(stoptimes) == 0 { errs = append(errs, causes.NewEmptyTripError(len(stoptimes))) return errs // assumes >= 1 below @@ -31,32 +40,59 @@ func ValidateStopTimes(stoptimes []gtfs.StopTime) []error { if len(stoptimes) < 2 { errs = append(errs, causes.NewEmptyTripError(len(stoptimes))) } - if lastSt := stoptimes[len(stoptimes)-1]; lastSt.ArrivalTime.Int() <= 0 { - errs = append(errs, causes.NewSequenceError("arrival_time", lastSt.ArrivalTime.String())) + + // 2. First stop validation: Must have departure_time OR time window + firstSt := stoptimes[0] + if firstSt.DepartureTime.Int() <= 0 && !hasTimeWindow(firstSt) { + errs = append(errs, causes.NewSequenceError("departure_time", "missing on first stop (required unless time window present)")) + } + + // 3. Last stop validation: Must have arrival_time OR time window + lastSt := stoptimes[len(stoptimes)-1] + if lastSt.ArrivalTime.Int() <= 0 && !hasTimeWindow(lastSt) { + errs = append(errs, causes.NewSequenceError("arrival_time", "missing on last stop (required unless time window present)")) } + + // Initialize tracking variables lastDist := stoptimes[0].ShapeDistTraveled - lastTime := stoptimes[0].DepartureTime + lastScheduledTime := stoptimes[0].DepartureTime // Track time only for scheduled stops lastSequence := stoptimes[0].StopSequence + + // 4-6. Validate stop sequences, time progression, and shape distances for _, st := range stoptimes[1:] { - // Ensure we do not have duplicate StopSequennce + // 4. Stop sequence validation: No duplicates, must increase if st.StopSequence == lastSequence { errs = append(errs, causes.NewSequenceError("stop_sequence", st.StopSequence.String())) } else { lastSequence = st.StopSequence } - // Ensure the arrows of time are pointing towards the future. - if st.ArrivalTime.Int() > 0 && st.ArrivalTime.Int() < lastTime.Int() { - errs = append(errs, causes.NewSequenceError("arrival_time", st.ArrivalTime.String())) - } else if st.DepartureTime.Int() > 0 && st.DepartureTime.Int() < st.ArrivalTime.Int() { - errs = append(errs, causes.NewSequenceError("departure_time", st.DepartureTime.String())) - } else if st.DepartureTime.Int() > 0 { - lastTime = st.DepartureTime + + // 5. Time progression validation (only for scheduled stops, skip flex stops) + if !hasTimeWindow(st) { + // This is a scheduled stop with arrival/departure times + if st.ArrivalTime.Int() > 0 && lastScheduledTime.Int() > 0 && st.ArrivalTime.Int() < lastScheduledTime.Int() { + errs = append(errs, causes.NewSequenceError("arrival_time", st.ArrivalTime.String())) + } + if st.DepartureTime.Int() > 0 && st.ArrivalTime.Int() > 0 && st.DepartureTime.Int() < st.ArrivalTime.Int() { + errs = append(errs, causes.NewSequenceError("departure_time", st.DepartureTime.String())) + } + // Update last scheduled time for next comparison + if st.DepartureTime.Int() > 0 { + lastScheduledTime = st.DepartureTime + } else if st.ArrivalTime.Int() > 0 { + lastScheduledTime = st.ArrivalTime + } } - if st.ShapeDistTraveled.Valid && st.ShapeDistTraveled.Val < lastDist.Val { + // else: Flex stop with time window - skip time progression validation + + // 6. Shape distance validation: Must increase when present + if st.ShapeDistTraveled.Valid && lastDist.Valid && st.ShapeDistTraveled.Val < lastDist.Val { errs = append(errs, causes.NewSequenceError("shape_dist_traveled", st.ShapeDistTraveled.String())) - } else if st.ShapeDistTraveled.Valid { + } + if st.ShapeDistTraveled.Valid { lastDist = st.ShapeDistTraveled } } + return errs } diff --git a/testdata/gtfs-external/ctran-flex.zip b/testdata/gtfs-external/ctran-flex.zip new file mode 100644 index 0000000000000000000000000000000000000000..53fb0832956f8bc77c8949f8b5f869d73564ae96 GIT binary patch literal 89592 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ⅅ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

j6}zE7h`x147@kT`0`9Gutfda?S;Y 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 L=J5OP0D%7kUa*Gw literal 0 HcmV?d00001 From e1105510ebdd78b746a3125fc681a6c713f9be7b Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 04:11:51 -0800 Subject: [PATCH 10/17] Tests --- gtfs/gtfs_test.go | 97 +++---- gtfs/stop_time_test.go | 88 +++---- internal/testutil/expect.go | 8 + rules/stop_time_sequence_test.go | 416 ++++++++++++++++++++++++++----- 4 files changed, 461 insertions(+), 148 deletions(-) diff --git a/gtfs/gtfs_test.go b/gtfs/gtfs_test.go index 5c712a415..e3c060095 100644 --- a/gtfs/gtfs_test.go +++ b/gtfs/gtfs_test.go @@ -2,77 +2,84 @@ package gtfs import ( "fmt" + "strings" "testing" "github.com/interline-io/transitland-lib/tt" - "github.com/stretchr/testify/assert" ) ///////////////////////// -// Test helpers +// Test helpers - local implementations to avoid import cycle with internal/testutil -// ExpectedError describes an expected validation error -type ExpectedError struct { - Field string // The field name that should be mentioned in the error (required) - ErrorType string // The error type (required: FieldParseError, InvalidFieldError, ConditionallyRequiredFieldError, ConditionallyForbiddenFieldError) - Filename string // The filename where the error occurred (optional) - EntityID string // The entity ID where the error occurred (optional) +// ExpectError describes an expected validation error (matches testutil.ExpectError interface) +type ExpectError struct { + Field string + ErrorType string + Filename string + EntityID string } -// expectErrors converts string tuples into ExpectedError structs. -// Each string should be in the format " ". -// Filename and entity_id are optional and can be empty strings. -// Example: expectErrors("InvalidFieldError stop_sequence", "ConditionallyRequiredFieldError stop_id stops.txt stop1") -func expectErrors(errorStrings ...string) []ExpectedError { - var errors []ExpectedError +// ParseExpectErrors converts shorthand error strings to ExpectError structs +// Format: "ErrorType:Field:Filename:EntityID" (Filename and EntityID are optional) +func ParseExpectErrors(errorStrings ...string) []ExpectError { + var errors []ExpectError for _, s := range errorStrings { - var errType, field, filename, entityID string - fmt.Sscanf(s, "%s %s %s %s", &errType, &field, &filename, &entityID) - errors = append(errors, ExpectedError{ - ErrorType: errType, - Field: field, - Filename: filename, - EntityID: entityID, + parts := strings.Split(s, ":") + // Pad to 4 parts + for len(parts) < 4 { + parts = append(parts, "") + } + errors = append(errors, ExpectError{ + ErrorType: parts[0], + Field: parts[1], + Filename: parts[2], + EntityID: parts[3], }) } return errors } -// checkErrors is a helper function to validate errors (both conditional and non-conditional) -func checkErrors(t *testing.T, errs []error, expectedErrors []ExpectedError) { +// CheckErrors validates that actual errors match expected errors +func CheckErrors(expectedErrors []ExpectError, errs []error, t *testing.T) { t.Helper() - if len(expectedErrors) == 0 { - assert.Empty(t, errs, "Expected no validation errors") + if len(expectedErrors) == 0 && len(errs) == 0 { return } - // Validate that all expected errors have both field and error type defined - for _, expected := range expectedErrors { - if expected.Field == "" { - t.Fatal("ExpectedError must have Field defined") - } - if expected.ErrorType == "" { - t.Fatal("ExpectedError must have ErrorType defined") + if len(expectedErrors) != len(errs) { + t.Errorf("Expected %d errors, got %d", len(expectedErrors), len(errs)) + for _, err := range errs { + t.Logf(" Actual error: %v", err) } + return } - assert.Equal(t, len(expectedErrors), len(errs), "Number of errors should match expected") + // Check that each expected error matches an actual error + for i, expected := range expectedErrors { + if i >= len(errs) { + break + } + errStr := errs[i].Error() + errType := fmt.Sprintf("%T", errs[i]) - // Check that each expected error is present - for _, expected := range expectedErrors { - found := false - for _, err := range errs { - errStr := err.Error() - // Check if error contains both the field name and error type - if errStr != "" { - // Simple string matching for now - could be more sophisticated - found = true - break - } + // Extract just the type name without package path + if idx := strings.LastIndex(errType, "."); idx >= 0 { + errType = errType[idx+1:] + } + // Remove pointer marker + errType = strings.TrimPrefix(errType, "*") + + // Check that error string contains the expected field + if expected.Field != "" && !strings.Contains(errStr, expected.Field) { + t.Errorf("Error %d: expected field %q not found in error: %v", i, expected.Field, errStr) + } + + // Check error type matches + if expected.ErrorType != "" && errType != expected.ErrorType { + t.Errorf("Error %d: expected error type %q, got %q in error: %v", i, expected.ErrorType, errType, errStr) } - assert.True(t, found, "Expected error: %s:%s", expected.ErrorType, expected.Field) } } diff --git a/gtfs/stop_time_test.go b/gtfs/stop_time_test.go index 9c5af785c..76b9b2301 100644 --- a/gtfs/stop_time_test.go +++ b/gtfs/stop_time_test.go @@ -10,7 +10,7 @@ func TestStopTime_Errors(t *testing.T) { tests := []struct { name string stopTime *StopTime - expectedErrors []ExpectedError + expectedErrors []ExpectError }{ { name: "Valid: Basic stop time", @@ -32,7 +32,7 @@ func TestStopTime_Errors(t *testing.T) { ArrivalTime: tt.NewSeconds(3600), DepartureTime: tt.NewSeconds(3630), }, - expectedErrors: expectErrors("InvalidFieldError stop_sequence"), + expectedErrors: ParseExpectErrors("InvalidFieldError:stop_sequence"), }, { name: "Invalid: pickup_type out of range", @@ -44,7 +44,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3630), PickupType: tt.NewInt(4), }, - expectedErrors: expectErrors("InvalidFieldError pickup_type"), + expectedErrors: ParseExpectErrors("InvalidFieldError:pickup_type"), }, { name: "Invalid: drop_off_type out of range", @@ -56,7 +56,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3630), DropOffType: tt.NewInt(4), }, - expectedErrors: expectErrors("InvalidFieldError drop_off_type"), + expectedErrors: ParseExpectErrors("InvalidFieldError:drop_off_type"), }, { name: "Invalid: timepoint out of range", @@ -68,7 +68,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3630), Timepoint: tt.NewInt(2), }, - expectedErrors: expectErrors("InvalidFieldError timepoint"), + expectedErrors: ParseExpectErrors("InvalidFieldError:timepoint"), }, { name: "Invalid: departure_time before arrival_time", @@ -79,7 +79,7 @@ func TestStopTime_Errors(t *testing.T) { ArrivalTime: tt.NewSeconds(3600), DepartureTime: tt.NewSeconds(1800), }, - expectedErrors: expectErrors("InvalidFieldError departure_time"), + expectedErrors: ParseExpectErrors("InvalidFieldError:departure_time"), }, { name: "Invalid: continuous_pickup out of range", @@ -91,7 +91,7 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3690), ContinuousPickup: tt.NewInt(100), }, - expectedErrors: expectErrors("InvalidFieldError continuous_pickup"), + expectedErrors: ParseExpectErrors("InvalidFieldError:continuous_pickup"), }, { name: "Invalid: continuous_drop_off out of range", @@ -103,14 +103,14 @@ func TestStopTime_Errors(t *testing.T) { DepartureTime: tt.NewSeconds(3690), ContinuousDropOff: tt.NewInt(100), }, - expectedErrors: expectErrors("InvalidFieldError continuous_drop_off"), + expectedErrors: ParseExpectErrors("InvalidFieldError:continuous_drop_off"), }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { errs := tc.stopTime.Errors() - checkErrors(t, errs, tc.expectedErrors) + CheckErrors(tc.expectedErrors, errs, t) }) } } @@ -119,7 +119,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { tests := []struct { name string stopTime *StopTime - expectedErrors []ExpectedError + expectedErrors []ExpectError }{ // Location field mutual exclusion tests { @@ -152,7 +152,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { stopTime: &StopTime{ StopSequence: tt.NewInt(1), }, - expectedErrors: expectErrors("ConditionallyRequiredFieldError stop_id"), + expectedErrors: ParseExpectErrors("ConditionallyRequiredFieldError:stop_id"), }, { name: "Invalid: stop_id and location_group_id both present", @@ -160,10 +160,10 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), LocationGroupID: tt.NewKey("lg1"), }, - expectedErrors: expectErrors( - "ConditionallyForbiddenFieldError location_group_id", - "ConditionallyRequiredFieldError start_pickup_drop_off_window", - "ConditionallyRequiredFieldError end_pickup_drop_off_window", + expectedErrors: ParseExpectErrors( + "ConditionallyForbiddenFieldError:location_group_id", + "ConditionallyRequiredFieldError:start_pickup_drop_off_window", + "ConditionallyRequiredFieldError:end_pickup_drop_off_window", ), }, { @@ -172,10 +172,10 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), LocationID: tt.NewKey("loc1"), }, - expectedErrors: expectErrors( - "ConditionallyForbiddenFieldError location_id", - "ConditionallyRequiredFieldError start_pickup_drop_off_window", - "ConditionallyRequiredFieldError end_pickup_drop_off_window", + expectedErrors: ParseExpectErrors( + "ConditionallyForbiddenFieldError:location_id", + "ConditionallyRequiredFieldError:start_pickup_drop_off_window", + "ConditionallyRequiredFieldError:end_pickup_drop_off_window", ), }, { @@ -184,10 +184,10 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { LocationGroupID: tt.NewKey("lg1"), LocationID: tt.NewKey("loc1"), }, - expectedErrors: expectErrors( - "ConditionallyForbiddenFieldError location_id", - "ConditionallyRequiredFieldError start_pickup_drop_off_window", - "ConditionallyRequiredFieldError end_pickup_drop_off_window", + expectedErrors: ParseExpectErrors( + "ConditionallyForbiddenFieldError:location_id", + "ConditionallyRequiredFieldError:start_pickup_drop_off_window", + "ConditionallyRequiredFieldError:end_pickup_drop_off_window", ), }, { @@ -195,9 +195,9 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { stopTime: &StopTime{ LocationGroupID: tt.NewKey("lg1"), }, - expectedErrors: expectErrors( - "ConditionallyRequiredFieldError start_pickup_drop_off_window", - "ConditionallyRequiredFieldError end_pickup_drop_off_window", + expectedErrors: ParseExpectErrors( + "ConditionallyRequiredFieldError:start_pickup_drop_off_window", + "ConditionallyRequiredFieldError:end_pickup_drop_off_window", ), }, { @@ -205,9 +205,9 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { stopTime: &StopTime{ LocationID: tt.NewKey("loc1"), }, - expectedErrors: expectErrors( - "ConditionallyRequiredFieldError start_pickup_drop_off_window", - "ConditionallyRequiredFieldError end_pickup_drop_off_window", + expectedErrors: ParseExpectErrors( + "ConditionallyRequiredFieldError:start_pickup_drop_off_window", + "ConditionallyRequiredFieldError:end_pickup_drop_off_window", ), }, { @@ -225,7 +225,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), }, - expectedErrors: expectErrors("ConditionallyRequiredFieldError end_pickup_drop_off_window"), + expectedErrors: ParseExpectErrors("ConditionallyRequiredFieldError:end_pickup_drop_off_window"), }, { name: "Invalid: Only end window present", @@ -233,7 +233,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), EndPickupDropOffWindow: tt.NewSeconds(7200), }, - expectedErrors: expectErrors("ConditionallyRequiredFieldError start_pickup_drop_off_window"), + expectedErrors: ParseExpectErrors("ConditionallyRequiredFieldError:start_pickup_drop_off_window"), }, { name: "Invalid: End window before start window", @@ -242,7 +242,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StartPickupDropOffWindow: tt.NewSeconds(7200), EndPickupDropOffWindow: tt.NewSeconds(3600), }, - expectedErrors: expectErrors("ConditionallyRequiredFieldError end_pickup_drop_off_window"), + expectedErrors: ParseExpectErrors("InvalidFieldError:end_pickup_drop_off_window"), }, { name: "Invalid: Time window with arrival_time", @@ -252,7 +252,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), ArrivalTime: tt.NewSeconds(10800), }, - expectedErrors: expectErrors("ConditionallyForbiddenFieldError arrival_time"), + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:arrival_time"), }, { name: "Invalid: Time window with departure_time", @@ -262,7 +262,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), DepartureTime: tt.NewSeconds(10800), }, - expectedErrors: expectErrors("ConditionallyForbiddenFieldError departure_time"), + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:departure_time"), }, // pickup_type and drop_off_type with time windows { @@ -273,7 +273,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), PickupType: tt.NewInt(0), }, - expectedErrors: expectErrors("ConditionallyForbiddenFieldError pickup_type"), + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:pickup_type"), }, { name: "Invalid: pickup_type=3 with time windows", @@ -283,7 +283,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), PickupType: tt.NewInt(3), }, - expectedErrors: expectErrors("ConditionallyForbiddenFieldError pickup_type"), + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:pickup_type"), }, { name: "Valid: pickup_type=2 with time windows", @@ -303,7 +303,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), DropOffType: tt.NewInt(0), }, - expectedErrors: expectErrors("ConditionallyForbiddenFieldError drop_off_type"), + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:drop_off_type"), }, { name: "Valid: drop_off_type=2 with time windows", @@ -324,7 +324,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), ContinuousPickup: tt.NewInt(0), }, - expectedErrors: expectErrors("ConditionallyForbiddenFieldError continuous_pickup"), + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:continuous_pickup"), }, { name: "Invalid: continuous_pickup=2 with time windows", @@ -334,7 +334,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), ContinuousPickup: tt.NewInt(2), }, - expectedErrors: expectErrors("ConditionallyForbiddenFieldError continuous_pickup"), + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:continuous_pickup"), }, { name: "Valid: continuous_pickup=1 with time windows", @@ -354,7 +354,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { EndPickupDropOffWindow: tt.NewSeconds(7200), ContinuousDropOff: tt.NewInt(0), }, - expectedErrors: expectErrors("ConditionallyForbiddenFieldError continuous_drop_off"), + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:continuous_drop_off"), }, { name: "Valid: continuous_drop_off=1 with time windows", @@ -417,7 +417,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), MeanDurationFactor: tt.NewFloat(1.5), }, - expectedErrors: expectErrors("ConditionallyRequiredFieldError mean_duration_offset"), + expectedErrors: ParseExpectErrors("ConditionallyRequiredFieldError:mean_duration_offset"), }, { name: "Invalid: Only mean_duration_offset present", @@ -425,7 +425,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { StopID: tt.NewKey("stop1"), MeanDurationOffset: tt.NewFloat(300), }, - expectedErrors: expectErrors("ConditionallyRequiredFieldError mean_duration_factor"), + expectedErrors: ParseExpectErrors("ConditionallyRequiredFieldError:mean_duration_factor"), }, { name: "Invalid: mean_duration_factor is negative", @@ -434,7 +434,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { MeanDurationFactor: tt.NewFloat(-1.5), MeanDurationOffset: tt.NewFloat(300), }, - expectedErrors: expectErrors("ConditionallyRequiredFieldError mean_duration_factor"), + expectedErrors: ParseExpectErrors("InvalidFieldError:mean_duration_factor"), }, { name: "Valid: safe_duration_factor with mean_duration_factor", @@ -452,7 +452,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { errs := tc.stopTime.ConditionalErrors() - checkErrors(t, errs, tc.expectedErrors) + CheckErrors(tc.expectedErrors, errs, t) }) } } diff --git a/internal/testutil/expect.go b/internal/testutil/expect.go index 24882e231..95abffcb6 100644 --- a/internal/testutil/expect.go +++ b/internal/testutil/expect.go @@ -151,3 +151,11 @@ func ParseExpectError(value string) ExpectError { } return NewExpectError(v[2], v[3], v[1], v[0]) } + +func ParseExpectErrors(values ...string) []ExpectError { + var expecterrs []ExpectError + for _, value := range values { + expecterrs = append(expecterrs, ParseExpectError(value)) + } + return expecterrs +} diff --git a/rules/stop_time_sequence_test.go b/rules/stop_time_sequence_test.go index dec143306..f82d6c34c 100644 --- a/rules/stop_time_sequence_test.go +++ b/rules/stop_time_sequence_test.go @@ -5,80 +5,378 @@ import ( "testing" "github.com/interline-io/transitland-lib/gtfs" + "github.com/interline-io/transitland-lib/internal/testutil" "github.com/interline-io/transitland-lib/tt" ) -type expectTrip struct { - ExpectError string - ArrivalTime []int - DepartureTime []int - ShapeDistTraveled []float64 -} +// Test helpers for building stop_time sequences with various configurations. +// These helpers make it easy to construct test cases for both scheduled GTFS +// and GTFS-Flex trips with time windows. -func expectTripToStopTime(e expectTrip) []gtfs.StopTime { - ret := []gtfs.StopTime{} - for i := range e.ArrivalTime { - ret = append(ret, gtfs.StopTime{ - TripID: tt.NewString("1"), - StopID: tt.NewKey(strconv.Itoa(i)), - StopSequence: tt.NewInt(i), - ArrivalTime: tt.NewSeconds(e.ArrivalTime[i]), - DepartureTime: tt.NewSeconds(e.DepartureTime[i]), - ShapeDistTraveled: tt.NewFloat(e.ShapeDistTraveled[i]), - }) +// Helper to create a basic stop_time with common defaults +func makeStopTime(stopSeq int, arrivalTime, departureTime int) gtfs.StopTime { + return gtfs.StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey(strconv.Itoa(stopSeq)), + StopSequence: tt.NewInt(stopSeq), + ArrivalTime: tt.NewSeconds(arrivalTime), + DepartureTime: tt.NewSeconds(departureTime), } - return ret } -func TestValidateStopTimes(t *testing.T) { - // base cases - trips := []expectTrip{ - {"1", []int{10, 20, 30}, []int{10, 20, 30}, []float64{0, 1, 2}}, // all specified - {"2", []int{10, 0, 30}, []int{10, 0, 30}, []float64{0, 1, 2}}, // ends specified - {"3", []int{10, 20, 30}, []int{10, 20, 30}, []float64{0, 0, 0}}, // no dist - {"4", []int{0, 20, 30}, []int{10, 20, 30}, []float64{0, 1, 2}}, // missing first arrival_time - {"5", []int{10, 20, 30}, []int{10, 20, 0}, []float64{0, 1, 2}}, // missing last departure_time - {"6", []int{10, 20, 30}, []int{10, 20, 30}, []float64{0, 1, 2}}, // two is OK +// Helper to create a flex stop_time with time windows +func makeFlexStopTime(stopSeq int, startWindow, endWindow int) gtfs.StopTime { + st := gtfs.StopTime{ + TripID: tt.NewString("trip1"), + StopID: tt.NewKey(strconv.Itoa(stopSeq)), + StopSequence: tt.NewInt(stopSeq), } - for _, et := range trips { - t.Run(et.ExpectError, func(t *testing.T) { - stoptimes := expectTripToStopTime(et) - if errs := ValidateStopTimes(stoptimes); len(errs) > 0 { - t.Errorf("got %d errors, expected %d: %s", len(errs), 0, errs) - } - }) + if startWindow > 0 { + st.StartPickupDropOffWindow = tt.NewSeconds(startWindow) + } + if endWindow > 0 { + st.EndPickupDropOffWindow = tt.NewSeconds(endWindow) } - // error cases - errortrips := []expectTrip{ - {"Error:OneStopTime", []int{10}, []int{10}, []float64{0}}, - {"Error:NoFinalArrivalTime", []int{10, 0}, []int{10, 0}, []float64{0, 0}}, - {"SequenceError:departure_time", []int{10, 20, 5}, []int{10, 20, 5}, []float64{0, 1, 2}}, - {"SequenceError:shape_pt_traveled", []int{10, 20, 30}, []int{10, 20, 30}, []float64{1, 2, 1}}, + return st +} + +// Helper to add shape distance to a stop_time +func withShapeDist(st gtfs.StopTime, dist float64) gtfs.StopTime { + st.ShapeDistTraveled = tt.NewFloat(dist) + return st +} + +// Helper to override stop sequence +func withStopSequence(st gtfs.StopTime, seq int) gtfs.StopTime { + st.StopSequence = tt.NewInt(seq) + return st +} + +func TestValidateStopTimes(t *testing.T) { + tests := []struct { + name string + stopTimes []gtfs.StopTime + expectedErrors []testutil.ExpectError + }{ + // ===== VALID SCHEDULED TRIPS ===== + { + name: "valid_basic_scheduled_trip", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 2000, 2000), + makeStopTime(2, 3000, 3000), + }, + expectedErrors: nil, + }, + { + name: "valid_missing_intermediate_times", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 0, 0), // intermediate can be missing + makeStopTime(2, 3000, 3000), + }, + expectedErrors: nil, + }, + { + name: "valid_first_missing_arrival", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 0, 1000), // first arrival_time can be missing + makeStopTime(1, 2000, 2000), + }, + expectedErrors: nil, + }, + { + name: "valid_last_missing_departure", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 2000, 0), // last departure_time can be missing + }, + expectedErrors: nil, + }, + { + name: "valid_with_shape_distances", + stopTimes: []gtfs.StopTime{ + withShapeDist(makeStopTime(0, 1000, 1000), 0.0), + withShapeDist(makeStopTime(1, 2000, 2000), 5.5), + withShapeDist(makeStopTime(2, 3000, 3000), 12.3), + }, + expectedErrors: nil, + }, + { + name: "valid_no_shape_distances", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 2000, 2000), + makeStopTime(2, 3000, 3000), + }, + expectedErrors: nil, + }, + { + name: "valid_partial_shape_distances", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + withShapeDist(makeStopTime(1, 2000, 2000), 5.5), + withShapeDist(makeStopTime(2, 3000, 3000), 12.3), + }, + expectedErrors: nil, + }, + + // ===== VALID FLEX TRIPS ===== + { + name: "valid_flex_trip_start_window", + stopTimes: []gtfs.StopTime{ + makeFlexStopTime(0, 1000, 0), // start_window only + makeFlexStopTime(1, 2000, 0), + makeFlexStopTime(2, 3000, 0), + }, + expectedErrors: nil, + }, + { + name: "valid_flex_trip_end_window", + stopTimes: []gtfs.StopTime{ + makeFlexStopTime(0, 0, 1000), // end_window only + makeFlexStopTime(1, 0, 2000), + makeFlexStopTime(2, 0, 3000), + }, + expectedErrors: nil, + }, + { + name: "valid_flex_trip_both_windows", + stopTimes: []gtfs.StopTime{ + makeFlexStopTime(0, 1000, 2000), // both windows + makeFlexStopTime(1, 2000, 3000), + makeFlexStopTime(2, 3000, 4000), + }, + expectedErrors: nil, + }, + + // ===== VALID MIXED TRIPS ===== + { + name: "valid_mixed_scheduled_then_flex", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), // scheduled + makeStopTime(1, 2000, 2000), // scheduled + makeFlexStopTime(2, 3000, 4000), // flex + }, + expectedErrors: nil, + }, + { + name: "valid_mixed_flex_then_scheduled", + stopTimes: []gtfs.StopTime{ + makeFlexStopTime(0, 1000, 2000), // flex + makeStopTime(1, 3000, 3000), // scheduled + makeStopTime(2, 4000, 4000), // scheduled + }, + expectedErrors: nil, + }, + { + name: "valid_mixed_interleaved", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), // scheduled + makeFlexStopTime(1, 2000, 3000), // flex + makeStopTime(2, 4000, 4000), // scheduled + }, + expectedErrors: nil, + }, + + // ===== ERRORS: EMPTY/TOO FEW STOPS ===== + { + name: "error_empty_trip", + stopTimes: []gtfs.StopTime{}, + expectedErrors: testutil.ParseExpectErrors("EmptyTripError"), + }, + { + name: "error_single_stop", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + }, + expectedErrors: testutil.ParseExpectErrors("EmptyTripError"), + }, + + // ===== ERRORS: FIRST STOP ===== + { + name: "error_first_stop_no_departure_or_window", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 0), // missing departure_time and no time window + makeStopTime(1, 2000, 2000), + }, + expectedErrors: testutil.ParseExpectErrors("SequenceError:departure_time"), + }, + + // ===== ERRORS: LAST STOP ===== + { + name: "error_last_stop_no_arrival_or_window", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 0, 2000), // missing arrival_time and no time window + }, + expectedErrors: testutil.ParseExpectErrors("SequenceError:arrival_time"), + }, + + // ===== ERRORS: STOP SEQUENCE ===== + { + name: "error_duplicate_stop_sequence", + stopTimes: []gtfs.StopTime{ + makeStopTime(1, 1000, 1000), + makeStopTime(2, 2000, 2000), + withStopSequence(makeStopTime(2, 3000, 3000), 2), // duplicate sequence + }, + expectedErrors: testutil.ParseExpectErrors("SequenceError:stop_sequence"), + }, + { + name: "error_multiple_duplicate_sequences", + stopTimes: []gtfs.StopTime{ + makeStopTime(5, 1000, 1000), + withStopSequence(makeStopTime(1, 2000, 2000), 5), // duplicate + makeStopTime(10, 3000, 3000), + withStopSequence(makeStopTime(3, 4000, 4000), 10), // duplicate + }, + expectedErrors: testutil.ParseExpectErrors( + "SequenceError:stop_sequence", + "SequenceError:stop_sequence", + ), + }, + + // ===== ERRORS: TIME PROGRESSION (SCHEDULED STOPS ONLY) ===== + { + name: "error_arrival_before_previous_departure", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 500, 2000), // arrival < previous departure + }, + expectedErrors: testutil.ParseExpectErrors("SequenceError:arrival_time"), + }, + { + name: "error_departure_before_arrival_same_stop", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 2000, 1500), // departure < arrival at same stop + }, + expectedErrors: testutil.ParseExpectErrors("SequenceError:departure_time"), + }, + { + name: "error_multiple_time_violations", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 500, 2000), // arrival < previous departure + makeStopTime(2, 3000, 2500), // departure < arrival + }, + expectedErrors: testutil.ParseExpectErrors( + "SequenceError:arrival_time", + "SequenceError:departure_time", + ), + }, + { + name: "error_time_goes_backward", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), + makeStopTime(1, 2000, 2000), + makeStopTime(2, 1500, 1500), // times go backward + }, + expectedErrors: testutil.ParseExpectErrors("SequenceError:arrival_time"), + }, + + // ===== NO ERRORS: TIME PROGRESSION WITH FLEX STOPS ===== + { + name: "no_error_flex_stop_skips_time_validation", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), // scheduled at 1000 + makeFlexStopTime(1, 100, 200), // flex with "earlier" window - should be ignored + makeStopTime(2, 3000, 3000), // scheduled at 3000 (compared to stop 0) + }, + expectedErrors: nil, + }, + { + name: "no_error_mixed_trip_time_validation_skips_flex", + stopTimes: []gtfs.StopTime{ + makeStopTime(0, 1000, 1000), // scheduled + makeFlexStopTime(1, 500, 600), // flex in between + makeStopTime(2, 2000, 2000), // scheduled (compared to stop 0, not stop 1) + }, + expectedErrors: nil, + }, + + // ===== ERRORS: SHAPE DISTANCE ===== + { + name: "error_shape_distance_decreases", + stopTimes: []gtfs.StopTime{ + withShapeDist(makeStopTime(0, 1000, 1000), 10.0), + withShapeDist(makeStopTime(1, 2000, 2000), 5.0), // distance decreases + }, + expectedErrors: testutil.ParseExpectErrors("SequenceError:shape_dist_traveled"), + }, + { + name: "error_multiple_shape_distance_violations", + stopTimes: []gtfs.StopTime{ + withShapeDist(makeStopTime(0, 1000, 1000), 10.0), + withShapeDist(makeStopTime(1, 2000, 2000), 5.0), // decreases + withShapeDist(makeStopTime(2, 3000, 3000), 15.0), + withShapeDist(makeStopTime(3, 4000, 4000), 12.0), // decreases + }, + expectedErrors: testutil.ParseExpectErrors( + "SequenceError:shape_dist_traveled", + "SequenceError:shape_dist_traveled", + ), + }, + { + name: "no_error_shape_distance_stays_same", + stopTimes: []gtfs.StopTime{ + withShapeDist(makeStopTime(0, 1000, 1000), 10.0), + withShapeDist(makeStopTime(1, 2000, 2000), 10.0), // same is OK + withShapeDist(makeStopTime(2, 3000, 3000), 15.0), + }, + expectedErrors: nil, + }, + + // ===== MULTIPLE ERROR TYPES ===== + { + name: "error_combined_sequence_and_time", + stopTimes: []gtfs.StopTime{ + makeStopTime(1, 1000, 1000), + makeStopTime(1, 500, 2000), // duplicate sequence + time violation + withStopSequence(makeStopTime(2, 3000, 3000), 1), // another duplicate sequence + }, + expectedErrors: testutil.ParseExpectErrors( + "SequenceError:stop_sequence", + "SequenceError:arrival_time", + "SequenceError:stop_sequence", + ), + }, + { + name: "error_all_validation_types", + stopTimes: []gtfs.StopTime{ + withShapeDist(makeStopTime(1, 1000, 0), 10.0), // missing departure_time + withShapeDist(makeStopTime(1, 2000, 2000), 5.0), // duplicate sequence + shape decreases + withShapeDist(withStopSequence(makeStopTime(2, 0, 3000), 1), 3.0), // another duplicate sequence + missing arrival + shape decreases + }, + expectedErrors: testutil.ParseExpectErrors( + "SequenceError:departure_time", + "SequenceError:arrival_time", + "SequenceError:stop_sequence", + "SequenceError:shape_dist_traveled", + "SequenceError:stop_sequence", + "SequenceError:shape_dist_traveled", + ), + }, } - for _, et := range errortrips { - t.Run(et.ExpectError, func(t *testing.T) { - stoptimes := expectTripToStopTime(et) - if errs := ValidateStopTimes(stoptimes); len(errs) != 1 { - t.Errorf("expected 1 error, got 0") - } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateStopTimes(tt.stopTimes) + testutil.CheckErrors(tt.expectedErrors, errs, t) }) } - // Check for duplicate IDs - errorStopSequence := expectTrip{"", []int{10, 20, 30}, []int{10, 20, 30}, []float64{0, 1, 2}} - t.Run("SequenceError:stop_sequence", func(t *testing.T) { - stoptimes := expectTripToStopTime(errorStopSequence) - stoptimes[0].StopSequence.Set(1) - stoptimes[1].StopSequence.Set(2) - stoptimes[2].StopSequence.Set(2) - if errs := ValidateStopTimes(stoptimes); len(errs) != 1 { - t.Errorf("expected 1 error, got 0") - } - }) } func BenchmarkValidateStopTime(b *testing.B) { - trip := expectTrip{"1", []int{10, 20, 30, 40, 50, 60}, []int{10, 20, 30, 40, 50, 60}, []float64{0, 1, 2, 3, 4, 5, 6}} - stoptimes := expectTripToStopTime(trip) + stoptimes := []gtfs.StopTime{ + makeStopTime(0, 10, 10), + makeStopTime(1, 20, 20), + makeStopTime(2, 30, 30), + makeStopTime(3, 40, 40), + makeStopTime(4, 50, 50), + makeStopTime(5, 60, 60), + } b.ResetTimer() for n := 0; n < b.N; n++ { ValidateStopTimes(stoptimes) From b3b833636480aeae91e084801fe5c6181998fc0f Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 04:38:33 -0800 Subject: [PATCH 11/17] Adjust stop time validation --- gtfs/booking_rule_test.go | 292 +++++++++++++++++++++++++++++++ gtfs/location_group_stop_test.go | 212 ++++++++++++++++++++++ gtfs/location_group_test.go | 106 +++++++++++ gtfs/stop_time_test.go | 95 +++++++--- rules/stop_time_sequence.go | 18 +- rules/stop_time_sequence_test.go | 15 +- 6 files changed, 692 insertions(+), 46 deletions(-) create mode 100644 gtfs/booking_rule_test.go create mode 100644 gtfs/location_group_stop_test.go create mode 100644 gtfs/location_group_test.go diff --git a/gtfs/booking_rule_test.go b/gtfs/booking_rule_test.go new file mode 100644 index 000000000..c8f176e1f --- /dev/null +++ b/gtfs/booking_rule_test.go @@ -0,0 +1,292 @@ +package gtfs + +import ( + "testing" + + "github.com/interline-io/transitland-lib/tt" +) + +func TestBookingRule_ConditionalErrors(t *testing.T) { + tests := []struct { + name string + bookingRule *BookingRule + expectedErrors []ExpectError + }{ + // ===== BOOKING_TYPE=0 TESTS ===== + { + name: "Valid: booking_type=0 with minimal fields", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule1"), + BookingType: tt.NewInt(0), + PriorNoticeDurationMin: tt.NewInt(15), + }, + expectedErrors: nil, + }, + { + name: "Invalid: booking_type=0 with prior_notice_duration_max", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule2"), + BookingType: tt.NewInt(0), + PriorNoticeDurationMin: tt.NewInt(15), + PriorNoticeDurationMax: tt.NewInt(60), + }, + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:prior_notice_duration_max"), + }, + { + name: "Invalid: booking_type=0 with prior_notice_start_day", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule3"), + BookingType: tt.NewInt(0), + PriorNoticeDurationMin: tt.NewInt(15), + PriorNoticeStartDay: tt.NewInt(1), + }, + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:prior_notice_start_day"), + }, + { + name: "Invalid: booking_type=0 with both forbidden fields", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule4"), + BookingType: tt.NewInt(0), + PriorNoticeDurationMin: tt.NewInt(15), + PriorNoticeDurationMax: tt.NewInt(60), + PriorNoticeStartDay: tt.NewInt(1), + }, + expectedErrors: ParseExpectErrors( + "ConditionallyForbiddenFieldError:prior_notice_duration_max", + "ConditionallyForbiddenFieldError:prior_notice_start_day", + ), + }, + { + name: "Invalid: booking_type=0 with prior_notice_service_id", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule5"), + BookingType: tt.NewInt(0), + PriorNoticeServiceID: tt.NewKey("service1"), + }, + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:prior_notice_service_id"), + }, + + // ===== BOOKING_TYPE=1 TESTS ===== + { + name: "Invalid: booking_type=1 without prior_notice_duration_min", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule6"), + BookingType: tt.NewInt(1), + }, + expectedErrors: ParseExpectErrors("ConditionallyRequiredFieldError:prior_notice_duration_min"), + }, + { + name: "Valid: booking_type=1 with required fields", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule7"), + BookingType: tt.NewInt(1), + PriorNoticeDurationMin: tt.NewInt(30), + }, + expectedErrors: nil, + }, + { + name: "Valid: booking_type=1 with optional prior_notice_duration_max", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule8"), + BookingType: tt.NewInt(1), + PriorNoticeDurationMin: tt.NewInt(30), + PriorNoticeDurationMax: tt.NewInt(120), + }, + expectedErrors: nil, + }, + { + name: "Invalid: booking_type=1 with prior_notice_duration_max and prior_notice_start_day", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule9"), + BookingType: tt.NewInt(1), + PriorNoticeDurationMin: tt.NewInt(30), + PriorNoticeDurationMax: tt.NewInt(120), + PriorNoticeStartDay: tt.NewInt(1), + }, + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:prior_notice_start_day"), + }, + { + name: "Valid: booking_type=1 with prior_notice_start_day without prior_notice_duration_max", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule10"), + BookingType: tt.NewInt(1), + PriorNoticeDurationMin: tt.NewInt(30), + PriorNoticeStartDay: tt.NewInt(1), + }, + expectedErrors: nil, + }, + { + name: "Invalid: booking_type=1 with prior_notice_service_id", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule11"), + BookingType: tt.NewInt(1), + PriorNoticeDurationMin: tt.NewInt(30), + PriorNoticeServiceID: tt.NewKey("service1"), + }, + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:prior_notice_service_id"), + }, + + // ===== BOOKING_TYPE=2 TESTS ===== + { + name: "Invalid: booking_type=2 without prior_notice_last_day", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule12"), + BookingType: tt.NewInt(2), + }, + expectedErrors: ParseExpectErrors("ConditionallyRequiredFieldError:prior_notice_last_day"), + }, + { + name: "Valid: booking_type=2 with required fields", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule13"), + BookingType: tt.NewInt(2), + PriorNoticeLastDay: tt.NewInt(1), + }, + expectedErrors: nil, + }, + { + name: "Invalid: booking_type=2 with prior_notice_duration_max", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule14"), + BookingType: tt.NewInt(2), + PriorNoticeLastDay: tt.NewInt(1), + PriorNoticeDurationMax: tt.NewInt(120), + }, + expectedErrors: ParseExpectErrors("ConditionallyForbiddenFieldError:prior_notice_duration_max"), + }, + { + name: "Valid: booking_type=2 with prior_notice_service_id", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule15"), + BookingType: tt.NewInt(2), + PriorNoticeLastDay: tt.NewInt(1), + PriorNoticeServiceID: tt.NewKey("service1"), + }, + expectedErrors: nil, + }, + { + name: "Invalid: booking_type=2 with both prior_notice_last_day missing and prior_notice_duration_max present", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule16"), + BookingType: tt.NewInt(2), + PriorNoticeDurationMax: tt.NewInt(120), + }, + expectedErrors: ParseExpectErrors( + "ConditionallyRequiredFieldError:prior_notice_last_day", + "ConditionallyForbiddenFieldError:prior_notice_duration_max", + ), + }, + + // ===== TIME AND DAY DEPENDENCY TESTS ===== + { + name: "Invalid: prior_notice_last_time without prior_notice_last_day", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule17"), + BookingType: tt.NewInt(1), + PriorNoticeLastTime: tt.NewSeconds(3600), + }, + expectedErrors: ParseExpectErrors( + "ConditionallyRequiredFieldError:prior_notice_duration_min", + "ConditionallyRequiredFieldError:prior_notice_last_day", + ), + }, + { + name: "Valid: prior_notice_last_time with prior_notice_last_day", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule18"), + BookingType: tt.NewInt(2), + PriorNoticeLastDay: tt.NewInt(1), + PriorNoticeLastTime: tt.NewSeconds(3600), + }, + expectedErrors: nil, + }, + { + name: "Invalid: prior_notice_start_time without prior_notice_start_day", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule19"), + BookingType: tt.NewInt(1), + PriorNoticeStartTime: tt.NewSeconds(7200), + }, + expectedErrors: ParseExpectErrors( + "ConditionallyRequiredFieldError:prior_notice_duration_min", + "ConditionallyRequiredFieldError:prior_notice_start_day", + ), + }, + { + name: "Valid: prior_notice_start_time with prior_notice_start_day", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule20"), + BookingType: tt.NewInt(1), + PriorNoticeDurationMin: tt.NewInt(30), + PriorNoticeStartDay: tt.NewInt(2), + PriorNoticeStartTime: tt.NewSeconds(7200), + }, + expectedErrors: nil, + }, + + // ===== COMPLEX SCENARIOS ===== + { + name: "Valid: booking_type=2 with all compatible optional fields", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule21"), + BookingType: tt.NewInt(2), + PriorNoticeLastDay: tt.NewInt(3), + PriorNoticeLastTime: tt.NewSeconds(3600), + PriorNoticeStartDay: tt.NewInt(1), + PriorNoticeStartTime: tt.NewSeconds(7200), + PriorNoticeServiceID: tt.NewKey("service1"), + Message: tt.NewString("Please book in advance"), + PhoneNumber: tt.NewString("555-1234"), + }, + expectedErrors: nil, + }, + { + name: "Valid: booking_type=1 with all compatible optional fields", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule22"), + BookingType: tt.NewInt(1), + PriorNoticeDurationMin: tt.NewInt(15), + PriorNoticeDurationMax: tt.NewInt(90), + Message: tt.NewString("Advanced booking required"), + PickupMessage: tt.NewString("Call when ready"), + DropOffMessage: tt.NewString("Confirm drop-off"), + }, + expectedErrors: nil, + }, + { + name: "Valid: booking_type=0 with all compatible optional fields", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule23"), + BookingType: tt.NewInt(0), + PriorNoticeDurationMin: tt.NewInt(10), + Message: tt.NewString("Same-day service"), + InfoURL: tt.NewUrl("https://example.com/info"), + BookingURL: tt.NewUrl("https://example.com/book"), + }, + expectedErrors: nil, + }, + { + name: "Invalid: Multiple errors across different rules", + bookingRule: &BookingRule{ + BookingRuleID: tt.NewString("rule24"), + BookingType: tt.NewInt(1), + PriorNoticeDurationMax: tt.NewInt(120), + PriorNoticeStartDay: tt.NewInt(1), + PriorNoticeStartTime: tt.NewSeconds(3600), + PriorNoticeServiceID: tt.NewKey("service1"), + }, + expectedErrors: ParseExpectErrors( + "ConditionallyRequiredFieldError:prior_notice_duration_min", + "ConditionallyForbiddenFieldError:prior_notice_service_id", + "ConditionallyForbiddenFieldError:prior_notice_start_day", + ), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errs := tc.bookingRule.ConditionalErrors() + CheckErrors(tc.expectedErrors, errs, t) + }) + } +} diff --git a/gtfs/location_group_stop_test.go b/gtfs/location_group_stop_test.go new file mode 100644 index 000000000..87afcb903 --- /dev/null +++ b/gtfs/location_group_stop_test.go @@ -0,0 +1,212 @@ +package gtfs + +import ( + "testing" + + "github.com/interline-io/transitland-lib/tt" +) + +func TestLocationGroupStop_Errors(t *testing.T) { + tests := []struct { + name string + locationGroupStop *LocationGroupStop + expectedErrors []ExpectError + }{ + { + name: "Valid: basic location_group_stop", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg1"), + StopID: tt.NewKey("stop1"), + }, + expectedErrors: nil, + }, + { + name: "Valid: location_group_stop with alphanumeric IDs", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg_downtown_001"), + StopID: tt.NewKey("stop_main_street_123"), + }, + expectedErrors: nil, + }, + { + name: "Invalid: missing location_group_id", + locationGroupStop: &LocationGroupStop{ + StopID: tt.NewKey("stop1"), + }, + expectedErrors: ParseExpectErrors("RequiredFieldError:location_group_id"), + }, + { + name: "Invalid: missing stop_id", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg1"), + }, + expectedErrors: ParseExpectErrors("RequiredFieldError:stop_id"), + }, + { + name: "Invalid: missing both required fields", + locationGroupStop: &LocationGroupStop{}, + expectedErrors: ParseExpectErrors("RequiredFieldError:location_group_id", "RequiredFieldError:stop_id"), + }, + { + name: "Valid: location_group_stop with mixed case IDs", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("LG-Zone1"), + StopID: tt.NewKey("StopID-ABC"), + }, + expectedErrors: nil, + }, + { + name: "Valid: location_group_stop with long IDs", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("location_group_for_downtown_service_area_zone_1_extended"), + StopID: tt.NewKey("stop_at_main_street_and_first_avenue_northeast_corner"), + }, + expectedErrors: nil, + }, + { + name: "Valid: location_group_stop with unicode IDs", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("市中心区域"), + StopID: tt.NewKey("駅001"), + }, + expectedErrors: nil, + }, + { + name: "Valid: location_group_stop with URL-encoded IDs", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg%20zone%201"), + StopID: tt.NewKey("stop%2001"), + }, + expectedErrors: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errs := tt.CheckErrors(tc.locationGroupStop) + CheckErrors(tc.expectedErrors, errs, t) + }) + } +} + +func TestLocationGroupStop_EntityMethods(t *testing.T) { + lgs := LocationGroupStop{ + LocationGroupID: tt.NewKey("lg_test"), + StopID: tt.NewKey("stop_test"), + } + + if filename := lgs.Filename(); filename != "location_group_stops.txt" { + t.Errorf("Filename() = %q, want %q", filename, "location_group_stops.txt") + } + + if table := lgs.TableName(); table != "gtfs_location_group_stops" { + t.Errorf("TableName() = %q, want %q", table, "gtfs_location_group_stops") + } + + // Test with ID set + lgs.ID = 456 + if id := lgs.ID; id != 456 { + t.Errorf("ID = %d, want %d", id, 456) + } +} + +func TestLocationGroupStop_UpdateKeys(t *testing.T) { + tests := []struct { + name string + locationGroupStop *LocationGroupStop + setupEntityMap func() *tt.EntityMap + expectError bool + errorField string + }{ + { + name: "Valid: both keys resolve successfully", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg1"), + StopID: tt.NewKey("stop1"), + }, + setupEntityMap: func() *tt.EntityMap { + emap := tt.NewEntityMap() + emap.Set("location_groups.txt", "lg1", "100") + emap.Set("stops.txt", "stop1", "200") + return emap + }, + expectError: false, + }, + { + name: "Invalid: location_group_id not found", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg_unknown"), + StopID: tt.NewKey("stop1"), + }, + setupEntityMap: func() *tt.EntityMap { + emap := tt.NewEntityMap() + emap.Set("stops.txt", "stop1", "200") + return emap + }, + expectError: true, + errorField: "location_group_id", + }, + { + name: "Invalid: stop_id not found", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg1"), + StopID: tt.NewKey("stop_unknown"), + }, + setupEntityMap: func() *tt.EntityMap { + emap := tt.NewEntityMap() + emap.Set("location_groups.txt", "lg1", "100") + return emap + }, + expectError: true, + errorField: "stop_id", + }, + { + name: "Invalid: both keys not found", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg_unknown"), + StopID: tt.NewKey("stop_unknown"), + }, + setupEntityMap: func() *tt.EntityMap { + return tt.NewEntityMap() + }, + expectError: true, + errorField: "location_group_id", // First error is returned + }, + { + name: "Valid: keys with special characters", + locationGroupStop: &LocationGroupStop{ + LocationGroupID: tt.NewKey("lg:zone-1"), + StopID: tt.NewKey("stop_123-456"), + }, + setupEntityMap: func() *tt.EntityMap { + emap := tt.NewEntityMap() + emap.Set("location_groups.txt", "lg:zone-1", "100") + emap.Set("stops.txt", "stop_123-456", "200") + return emap + }, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + emap := tc.setupEntityMap() + err := tc.locationGroupStop.UpdateKeys(emap) + + if tc.expectError && err == nil { + t.Error("Expected error but got nil") + } + + if !tc.expectError && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + + if tc.expectError && err != nil && tc.errorField != "" { + errStr := err.Error() + if len(errStr) == 0 || errStr == "" { + t.Errorf("Expected error message containing %q but got empty error", tc.errorField) + } + } + }) + } +} diff --git a/gtfs/location_group_test.go b/gtfs/location_group_test.go new file mode 100644 index 000000000..be8f01b74 --- /dev/null +++ b/gtfs/location_group_test.go @@ -0,0 +1,106 @@ +package gtfs + +import ( + "testing" + + "github.com/interline-io/transitland-lib/tt" +) + +func TestLocationGroup_Errors(t *testing.T) { + tests := []struct { + name string + locationGroup *LocationGroup + expectedErrors []ExpectError + }{ + { + name: "Valid: location_group with ID and name", + locationGroup: &LocationGroup{ + LocationGroupID: tt.NewString("lg1"), + LocationGroupName: tt.NewString("Transit Mall"), + }, + expectedErrors: nil, + }, + { + name: "Valid: location_group with ID only", + locationGroup: &LocationGroup{ + LocationGroupID: tt.NewString("lg2"), + }, + expectedErrors: nil, + }, + { + name: "Invalid: location_group without required location_group_id", + locationGroup: &LocationGroup{ + LocationGroupName: tt.NewString("Transit Mall"), + }, + expectedErrors: ParseExpectErrors("RequiredFieldError:location_group_id"), + }, + { + name: "Valid: location_group with special characters in ID", + locationGroup: &LocationGroup{ + LocationGroupID: tt.NewString("lg_zone-1:downtown"), + LocationGroupName: tt.NewString("Zone 1: Downtown"), + }, + expectedErrors: nil, + }, + { + name: "Valid: location_group with unicode name", + locationGroup: &LocationGroup{ + LocationGroupID: tt.NewString("lg_unicode"), + LocationGroupName: tt.NewString("市中心區域 (Downtown)"), + }, + expectedErrors: nil, + }, + { + name: "Valid: location_group with long name", + locationGroup: &LocationGroup{ + LocationGroupID: tt.NewString("lg_long"), + LocationGroupName: tt.NewString("This is a very long location group name that describes a comprehensive service area covering multiple neighborhoods and districts"), + }, + expectedErrors: nil, + }, + { + name: "Valid: location_group with empty name (optional field)", + locationGroup: &LocationGroup{ + LocationGroupID: tt.NewString("lg_no_name"), + }, + expectedErrors: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errs := tt.CheckErrors(tc.locationGroup) + CheckErrors(tc.expectedErrors, errs, t) + }) + } +} + +func TestLocationGroup_EntityMethods(t *testing.T) { + lg := LocationGroup{ + LocationGroupID: tt.NewString("test_lg"), + LocationGroupName: tt.NewString("Test Group"), + } + + if key := lg.EntityKey(); key != "test_lg" { + t.Errorf("EntityKey() = %q, want %q", key, "test_lg") + } + + if filename := lg.Filename(); filename != "location_groups.txt" { + t.Errorf("Filename() = %q, want %q", filename, "location_groups.txt") + } + + if table := lg.TableName(); table != "gtfs_location_groups" { + t.Errorf("TableName() = %q, want %q", table, "gtfs_location_groups") + } + + // Test EntityID with no ID set + if entityID := lg.EntityID(); entityID != "test_lg" { + t.Errorf("EntityID() without ID = %q, want %q", entityID, "test_lg") + } + + // Test EntityID with ID set + lg.ID = 123 + if entityID := lg.EntityID(); entityID != "123" { + t.Errorf("EntityID() with ID = %q, want %q", entityID, "123") + } +} diff --git a/gtfs/stop_time_test.go b/gtfs/stop_time_test.go index 76b9b2301..0bd3529ed 100644 --- a/gtfs/stop_time_test.go +++ b/gtfs/stop_time_test.go @@ -105,33 +105,21 @@ func TestStopTime_Errors(t *testing.T) { }, expectedErrors: ParseExpectErrors("InvalidFieldError:continuous_drop_off"), }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - errs := tc.stopTime.Errors() - CheckErrors(tc.expectedErrors, errs, t) - }) - } -} - -func TestStopTime_ConditionalErrorsFlex(t *testing.T) { - tests := []struct { - name string - stopTime *StopTime - expectedErrors []ExpectError - }{ - // Location field mutual exclusion tests + // ConditionalErrors - Location field mutual exclusion tests { name: "Valid: Only stop_id present", stopTime: &StopTime{ - StopID: tt.NewKey("stop1"), + TripID: tt.NewString("trip1"), + StopID: tt.NewKey("stop1"), + StopSequence: tt.NewInt(1), }, expectedErrors: nil, }, { name: "Valid: Only location_group_id present with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), LocationGroupID: tt.NewKey("lg1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -141,6 +129,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: Only location_id present with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), LocationID: tt.NewKey("loc1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -150,6 +140,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: No location identifier", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), StopSequence: tt.NewInt(1), }, expectedErrors: ParseExpectErrors("ConditionallyRequiredFieldError:stop_id"), @@ -157,6 +148,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: stop_id and location_group_id both present", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), LocationGroupID: tt.NewKey("lg1"), }, @@ -169,8 +162,10 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: stop_id and location_id both present", stopTime: &StopTime{ - StopID: tt.NewKey("stop1"), - LocationID: tt.NewKey("loc1"), + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), + StopID: tt.NewKey("stop1"), + LocationID: tt.NewKey("loc1"), }, expectedErrors: ParseExpectErrors( "ConditionallyForbiddenFieldError:location_id", @@ -181,6 +176,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: location_group_id and location_id both present", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), LocationGroupID: tt.NewKey("lg1"), LocationID: tt.NewKey("loc1"), }, @@ -193,6 +190,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: location_group_id without time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), LocationGroupID: tt.NewKey("lg1"), }, expectedErrors: ParseExpectErrors( @@ -203,7 +202,9 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: location_id without time windows", stopTime: &StopTime{ - LocationID: tt.NewKey("loc1"), + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), + LocationID: tt.NewKey("loc1"), }, expectedErrors: ParseExpectErrors( "ConditionallyRequiredFieldError:start_pickup_drop_off_window", @@ -213,6 +214,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: Both time windows present and consistent", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -222,6 +225,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: Only start window present", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), }, @@ -230,6 +235,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: Only end window present", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), EndPickupDropOffWindow: tt.NewSeconds(7200), }, @@ -238,6 +245,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: End window before start window", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(7200), EndPickupDropOffWindow: tt.NewSeconds(3600), @@ -247,6 +256,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: Time window with arrival_time", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -257,6 +268,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: Time window with departure_time", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -268,6 +281,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: pickup_type=0 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -278,6 +293,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: pickup_type=3 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -288,6 +305,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: pickup_type=2 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -298,6 +317,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: drop_off_type=0 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -308,6 +329,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: drop_off_type=2 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -319,6 +342,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: continuous_pickup=0 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -329,6 +354,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: continuous_pickup=2 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -339,6 +366,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: continuous_pickup=1 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -349,6 +378,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: continuous_drop_off=0 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -359,6 +390,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: continuous_drop_off=1 with time windows", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), StartPickupDropOffWindow: tt.NewSeconds(3600), EndPickupDropOffWindow: tt.NewSeconds(7200), @@ -369,6 +402,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: pickup_booking_rule_id with pickup_type=2", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), PickupBookingRuleID: tt.NewKey("rule1"), PickupType: tt.NewInt(2), @@ -378,6 +413,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: pickup_booking_rule_id with pickup_type=0", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), PickupBookingRuleID: tt.NewKey("rule1"), PickupType: tt.NewInt(0), @@ -387,6 +424,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: drop_off_booking_rule_id with drop_off_type=2", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), DropOffBookingRuleID: tt.NewKey("rule1"), DropOffType: tt.NewInt(2), @@ -396,6 +435,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: drop_off_booking_rule_id with drop_off_type=0", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), DropOffBookingRuleID: tt.NewKey("rule1"), DropOffType: tt.NewInt(0), @@ -405,6 +446,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: Both mean_duration fields present", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), MeanDurationFactor: tt.NewFloat(1.5), MeanDurationOffset: tt.NewFloat(300), @@ -414,6 +457,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: Only mean_duration_factor present", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), MeanDurationFactor: tt.NewFloat(1.5), }, @@ -422,6 +467,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: Only mean_duration_offset present", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), MeanDurationOffset: tt.NewFloat(300), }, @@ -430,6 +477,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Invalid: mean_duration_factor is negative", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), MeanDurationFactor: tt.NewFloat(-1.5), MeanDurationOffset: tt.NewFloat(300), @@ -439,6 +488,8 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { { name: "Valid: safe_duration_factor with mean_duration_factor", stopTime: &StopTime{ + TripID: tt.NewString("trip1"), + StopSequence: tt.NewInt(1), StopID: tt.NewKey("stop1"), MeanDurationFactor: tt.NewFloat(1.5), MeanDurationOffset: tt.NewFloat(300), @@ -451,7 +502,7 @@ func TestStopTime_ConditionalErrorsFlex(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - errs := tc.stopTime.ConditionalErrors() + errs := tt.CheckErrors(tc.stopTime) CheckErrors(tc.expectedErrors, errs, t) }) } diff --git a/rules/stop_time_sequence.go b/rules/stop_time_sequence.go index 4954de94a..322e7078b 100644 --- a/rules/stop_time_sequence.go +++ b/rules/stop_time_sequence.go @@ -41,13 +41,9 @@ func ValidateStopTimes(stoptimes []gtfs.StopTime) []error { errs = append(errs, causes.NewEmptyTripError(len(stoptimes))) } - // 2. First stop validation: Must have departure_time OR time window - firstSt := stoptimes[0] - if firstSt.DepartureTime.Int() <= 0 && !hasTimeWindow(firstSt) { - errs = append(errs, causes.NewSequenceError("departure_time", "missing on first stop (required unless time window present)")) - } - - // 3. Last stop validation: Must have arrival_time OR time window + // 2. Last stop validation: Must have arrival_time OR time window + // Note: First stop departure_time is not required by GTFS spec + // (arrival time is meaningless at start of trip, departure time is meaningless at end of trip) lastSt := stoptimes[len(stoptimes)-1] if lastSt.ArrivalTime.Int() <= 0 && !hasTimeWindow(lastSt) { errs = append(errs, causes.NewSequenceError("arrival_time", "missing on last stop (required unless time window present)")) @@ -58,16 +54,16 @@ func ValidateStopTimes(stoptimes []gtfs.StopTime) []error { lastScheduledTime := stoptimes[0].DepartureTime // Track time only for scheduled stops lastSequence := stoptimes[0].StopSequence - // 4-6. Validate stop sequences, time progression, and shape distances + // 3-5. Validate stop sequences, time progression, and shape distances for _, st := range stoptimes[1:] { - // 4. Stop sequence validation: No duplicates, must increase + // 3. Stop sequence validation: No duplicates, must increase if st.StopSequence == lastSequence { errs = append(errs, causes.NewSequenceError("stop_sequence", st.StopSequence.String())) } else { lastSequence = st.StopSequence } - // 5. Time progression validation (only for scheduled stops, skip flex stops) + // 4. Time progression validation (only for scheduled stops, skip flex stops) if !hasTimeWindow(st) { // This is a scheduled stop with arrival/departure times if st.ArrivalTime.Int() > 0 && lastScheduledTime.Int() > 0 && st.ArrivalTime.Int() < lastScheduledTime.Int() { @@ -85,7 +81,7 @@ func ValidateStopTimes(stoptimes []gtfs.StopTime) []error { } // else: Flex stop with time window - skip time progression validation - // 6. Shape distance validation: Must increase when present + // 5. Shape distance validation: Must increase when present if st.ShapeDistTraveled.Valid && lastDist.Valid && st.ShapeDistTraveled.Val < lastDist.Val { errs = append(errs, causes.NewSequenceError("shape_dist_traveled", st.ShapeDistTraveled.String())) } diff --git a/rules/stop_time_sequence_test.go b/rules/stop_time_sequence_test.go index f82d6c34c..89645a290 100644 --- a/rules/stop_time_sequence_test.go +++ b/rules/stop_time_sequence_test.go @@ -193,16 +193,6 @@ func TestValidateStopTimes(t *testing.T) { expectedErrors: testutil.ParseExpectErrors("EmptyTripError"), }, - // ===== ERRORS: FIRST STOP ===== - { - name: "error_first_stop_no_departure_or_window", - stopTimes: []gtfs.StopTime{ - makeStopTime(0, 1000, 0), // missing departure_time and no time window - makeStopTime(1, 2000, 2000), - }, - expectedErrors: testutil.ParseExpectErrors("SequenceError:departure_time"), - }, - // ===== ERRORS: LAST STOP ===== { name: "error_last_stop_no_arrival_or_window", @@ -345,12 +335,11 @@ func TestValidateStopTimes(t *testing.T) { { name: "error_all_validation_types", stopTimes: []gtfs.StopTime{ - withShapeDist(makeStopTime(1, 1000, 0), 10.0), // missing departure_time + withShapeDist(makeStopTime(1, 1000, 1000), 10.0), // first stop is valid now withShapeDist(makeStopTime(1, 2000, 2000), 5.0), // duplicate sequence + shape decreases - withShapeDist(withStopSequence(makeStopTime(2, 0, 3000), 1), 3.0), // another duplicate sequence + missing arrival + shape decreases + withShapeDist(withStopSequence(makeStopTime(2, 0, 3000), 1), 3.0), // another duplicate sequence + missing arrival (last stop) + shape decreases }, expectedErrors: testutil.ParseExpectErrors( - "SequenceError:departure_time", "SequenceError:arrival_time", "SequenceError:stop_sequence", "SequenceError:shape_dist_traveled", From f6279606efc7a5f9d6b771591472f234b3f83687 Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 16:05:37 -0800 Subject: [PATCH 12/17] Move to best practices --- copier/copier.go | 2 -- validator/validator.go | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/copier/copier.go b/copier/copier.go index 9f36f6c0d..07feaf6ef 100644 --- a/copier/copier.go +++ b/copier/copier.go @@ -294,7 +294,6 @@ func NewCopier(ctx context.Context, reader adapters.Reader, writer adapters.Writ // GTFS-Flex validators &rules.FlexGeographyIDUniqueCheck{}, &rules.FlexStopLocationTypeCheck{}, - &rules.FlexLocationGroupEmptyCheck{}, &rules.FlexLocationGeometryCheck{}, &rules.FlexZoneIDConditionalCheck{}, ) @@ -618,7 +617,6 @@ func (copier *Copier) Copy(ctx context.Context) (*Result, error) { ) }, copier.copyCalendars, - // GTFS-Flex entities (must come before stop_times for proper reference validation) func() error { return batchCopy(copier, batchChan(r.BookingRules(), bs, nil)) }, func() error { return batchCopy(copier, batchChan(r.Locations(), bs, nil)) }, func() error { return batchCopy(copier, batchChan(r.LocationGroups(), bs, nil)) }, diff --git a/validator/validator.go b/validator/validator.go index c0f193649..ff5a7e5c6 100644 --- a/validator/validator.go +++ b/validator/validator.go @@ -379,6 +379,8 @@ func (v *Validator) copierOptions() copier.Options { cpOpts.AddExtensionWithLevel(&rules.ShapeMaxSegmentLengthCheck{ MaxAllowedDistance: 1_000_000, // 1000 km }, 1) + // GTFS-Flex best practice: location groups should have stops + cpOpts.AddExtensionWithLevel(&rules.FlexLocationGroupEmptyCheck{}, 1) } return cpOpts } From 66e0614dc8ed5915adc7eb8792c4fccc00810185 Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 16:05:59 -0800 Subject: [PATCH 13/17] Fix filename --- gtfs/stop_time.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gtfs/stop_time.go b/gtfs/stop_time.go index ad0cbeab1..81387737e 100644 --- a/gtfs/stop_time.go +++ b/gtfs/stop_time.go @@ -252,7 +252,7 @@ func (ent *StopTime) UpdateKeys(emap *tt.EntityMap) error { tt.TrySetField(emap.UpdateKey(&ent.TripID, "trips.txt"), "trip_id"), tt.TrySetField(emap.UpdateKey(&ent.StopID, "stops.txt"), "stop_id"), tt.TrySetField(emap.UpdateKey(&ent.LocationGroupID, "location_groups.txt"), "location_group_id"), - tt.TrySetField(emap.UpdateKey(&ent.LocationID, "locations.txt"), "location_id"), + tt.TrySetField(emap.UpdateKey(&ent.LocationID, "locations.geojson"), "location_id"), tt.TrySetField(emap.UpdateKey(&ent.PickupBookingRuleID, "booking_rules.txt"), "pickup_booking_rule_id"), tt.TrySetField(emap.UpdateKey(&ent.DropOffBookingRuleID, "booking_rules.txt"), "drop_off_booking_rule_id"), ) From 5435066fc7f892e71670b60ba57d3b87a9fa4091 Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 16:17:23 -0800 Subject: [PATCH 14/17] Review --- rules/stop_time_sequence.go | 2 ++ .../migrations/20251121000001_gtfs_flex.up.pgsql | 8 +++++++- schema/sqlite/sqlite.sql | 9 +++++++-- tlcsv/geojson.go | 5 +++++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/rules/stop_time_sequence.go b/rules/stop_time_sequence.go index 322e7078b..15c96856a 100644 --- a/rules/stop_time_sequence.go +++ b/rules/stop_time_sequence.go @@ -73,11 +73,13 @@ func ValidateStopTimes(stoptimes []gtfs.StopTime) []error { errs = append(errs, causes.NewSequenceError("departure_time", st.DepartureTime.String())) } // Update last scheduled time for next comparison + // Only update if this stop has explicit times (not interpolated/missing) if st.DepartureTime.Int() > 0 { lastScheduledTime = st.DepartureTime } else if st.ArrivalTime.Int() > 0 { lastScheduledTime = st.ArrivalTime } + // If both times are 0/missing, keep previous lastScheduledTime for next scheduled stop comparison } // else: Flex stop with time window - skip time progression validation diff --git a/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql b/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql index 2d256e2f5..65cd7f381 100644 --- a/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql +++ b/schema/postgres/migrations/20251121000001_gtfs_flex.up.pgsql @@ -45,7 +45,7 @@ CREATE TABLE public.gtfs_booking_rules ( prior_notice_last_time integer, prior_notice_start_day integer, prior_notice_start_time integer, - prior_notice_service_id text, + prior_notice_service_id bigint REFERENCES gtfs_calendars(id), message text, pickup_message text, drop_off_message text, @@ -94,6 +94,12 @@ ALTER TABLE public.gtfs_stop_times ALTER TABLE public.gtfs_stop_times ALTER COLUMN stop_id DROP NOT NULL; +-- GTFS-Flex: Add indexes on new foreign keys in stop_times for efficient queries and cascading deletes +CREATE INDEX ON gtfs_stop_times(location_group_id) WHERE location_group_id IS NOT NULL; +CREATE INDEX ON gtfs_stop_times(location_id) WHERE location_id IS NOT NULL; +CREATE INDEX ON gtfs_stop_times(pickup_booking_rule_id) WHERE pickup_booking_rule_id IS NOT NULL; +CREATE INDEX ON gtfs_stop_times(drop_off_booking_rule_id) WHERE drop_off_booking_rule_id IS NOT NULL; + COMMIT; diff --git a/schema/sqlite/sqlite.sql b/schema/sqlite/sqlite.sql index d8f26e477..42d530088 100644 --- a/schema/sqlite/sqlite.sql +++ b/schema/sqlite/sqlite.sql @@ -421,6 +421,10 @@ CREATE TABLE IF NOT EXISTS "gtfs_stop_times" ( CREATE INDEX idx_stop_times_trip_id ON "gtfs_stop_times"(trip_id); CREATE INDEX idx_gtfs_stop_times_stop_id ON "gtfs_stop_times"(stop_id); CREATE INDEX idx_gtfs_stop_times_feed_version_id ON "gtfs_stop_times"(feed_version_id); +CREATE INDEX idx_gtfs_stop_times_location_group_id ON "gtfs_stop_times"(location_group_id) WHERE location_group_id IS NOT NULL; +CREATE INDEX idx_gtfs_stop_times_location_id ON "gtfs_stop_times"(location_id) WHERE location_id IS NOT NULL; +CREATE INDEX idx_gtfs_stop_times_pickup_booking_rule_id ON "gtfs_stop_times"(pickup_booking_rule_id) WHERE pickup_booking_rule_id IS NOT NULL; +CREATE INDEX idx_gtfs_stop_times_drop_off_booking_rule_id ON "gtfs_stop_times"(drop_off_booking_rule_id) WHERE drop_off_booking_rule_id IS NOT NULL; CREATE TABLE IF NOT EXISTS "gtfs_fare_rules" ( "fare_id" integer NOT NULL, "route_id" int, @@ -1007,14 +1011,15 @@ CREATE TABLE IF NOT EXISTS "gtfs_booking_rules" ( "prior_notice_last_time" integer, "prior_notice_start_day" integer, "prior_notice_start_time" integer, - "prior_notice_service_id" varchar(255), + "prior_notice_service_id" integer, "message" text, "pickup_message" text, "drop_off_message" text, "phone_number" varchar(255), "info_url" text, "booking_url" text, - foreign key(feed_version_id) REFERENCES feed_versions(id) + foreign key(feed_version_id) REFERENCES feed_versions(id), + foreign key(prior_notice_service_id) references gtfs_calendars(id) ); CREATE INDEX idx_gtfs_booking_rules_feed_version_id ON "gtfs_booking_rules"(feed_version_id); CREATE UNIQUE INDEX idx_gtfs_booking_rules_fv_id ON "gtfs_booking_rules"(feed_version_id, booking_rule_id); diff --git a/tlcsv/geojson.go b/tlcsv/geojson.go index 994f5d63e..2d43d6755 100644 --- a/tlcsv/geojson.go +++ b/tlcsv/geojson.go @@ -20,10 +20,12 @@ type GeoJSONFeatureParser[T any] func(*geojson.Feature) (T, bool) // file format into GTFS entities. func readGeoJSON[T any](reader *Reader, filename string, parser GeoJSONFeatureParser[T]) ([]T, error) { var entities []T + var parseErr error err := reader.Adapter.OpenFile(filename, func(in io.Reader) { var fc geojson.FeatureCollection if err := json.NewDecoder(in).Decode(&fc); err != nil { + parseErr = err return } @@ -37,6 +39,9 @@ func readGeoJSON[T any](reader *Reader, filename string, parser GeoJSONFeaturePa if err != nil { return nil, err } + if parseErr != nil { + return nil, parseErr + } return entities, nil } From 5bbc3d85a5b58693bb95a47217f23aebf4b918cf Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 16:28:03 -0800 Subject: [PATCH 15/17] Separate testreader/testutil --- cmds/merge_cmd_test.go | 8 +++--- ext/builders/convex_hull_builder_test.go | 8 +++--- ext/builders/onestop_id_builder_test.go | 8 +++--- ext/builders/route_geometry_builder_test.go | 6 ++-- ext/builders/route_headway_builder_test.go | 6 ++-- ext/builders/route_stop_builder_test.go | 4 +-- extract/marker_test.go | 6 ++-- fetch/static_fetch_test.go | 4 +-- filters/redate_filter_test.go | 6 ++-- gtfs/gtfs_test.go | 2 +- importer/importer_test.go | 10 +++---- importer/unimporter_test.go | 6 ++-- internal/feedstate/manager_test.go | 16 +++++------ internal/geomcache/geomcache_test.go | 4 +-- internal/{testutil => testreader}/copy.go | 2 +- .../{testutil => testreader}/copy_test.go | 2 +- internal/{testutil => testreader}/entities.go | 2 +- internal/{testutil => testreader}/minimal.go | 2 +- .../{testutil => testreader}/readertester.go | 2 +- .../{testutil => testreader}/test_feeds.go | 2 +- service/tests/service_test.go | 4 +-- stats/fvfi_test.go | 6 ++-- stats/fvsl_test.go | 6 ++-- stats/fvsw_test.go | 8 +++--- tlcsv/adapter_test.go | 28 +++++++++---------- tlcsv/reader_benchmark_test.go | 6 ++-- tlcsv/reader_test.go | 6 ++-- tlcsv/writer_benchmark_test.go | 6 ++-- tlcsv/writer_test.go | 6 ++-- tldb/tldbtest/adapter_benchmark.go | 6 ++-- validator/validator_test.go | 3 +- 31 files changed, 96 insertions(+), 95 deletions(-) rename internal/{testutil => testreader}/copy.go (98%) rename internal/{testutil => testreader}/copy_test.go (97%) rename internal/{testutil => testreader}/entities.go (99%) rename internal/{testutil => testreader}/minimal.go (99%) rename internal/{testutil => testreader}/readertester.go (99%) rename internal/{testutil => testreader}/test_feeds.go (99%) diff --git a/cmds/merge_cmd_test.go b/cmds/merge_cmd_test.go index 6bd7d65ad..8f62608f3 100644 --- a/cmds/merge_cmd_test.go +++ b/cmds/merge_cmd_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/interline-io/transitland-lib/ext" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tt" "github.com/stretchr/testify/assert" ) @@ -13,8 +13,8 @@ import ( func TestMerge(t *testing.T) { ctx := context.TODO() t.Run("merge", func(t *testing.T) { - f1 := testutil.ExampleFeedBART - f2 := testutil.ExampleFeedCaltrain + f1 := testreader.ExampleFeedBART + f2 := testreader.ExampleFeedCaltrain cmd := MergeCommand{} tdir := t.TempDir() if err := cmd.Parse([]string{tdir, f1.URL, f2.URL}); err != nil { @@ -28,7 +28,7 @@ func TestMerge(t *testing.T) { t.Fatal(err) } entCount := map[string]int{} - testutil.AllEntities(outReader, func(ent tt.Entity) { + testreader.AllEntities(outReader, func(ent tt.Entity) { entCount[ent.Filename()] += 1 }) expectCount := map[string]int{} diff --git a/ext/builders/convex_hull_builder_test.go b/ext/builders/convex_hull_builder_test.go index 638de1634..2be8989b8 100644 --- a/ext/builders/convex_hull_builder_test.go +++ b/ext/builders/convex_hull_builder_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/interline-io/transitland-lib/internal/testpath" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/stretchr/testify/assert" ) @@ -19,7 +19,7 @@ func TestConvexHullBuilder(t *testing.T) { } groups := map[string]testgroup{ "ExampleFeed": { - testutil.ExampleZip.URL, + testreader.ExampleZip.URL, []testcase{ { FeedVersionGeometry: []float64{-117.133162, 36.425288, -116.81797, 36.88108, -116.76821, 36.914893, -116.751677, 36.915682, -116.40094, 36.641496, -117.133162, 36.425288}, @@ -30,7 +30,7 @@ func TestConvexHullBuilder(t *testing.T) { }, }, "Caltrain": { - testutil.ExampleFeedCaltrain.URL, + testreader.ExampleFeedCaltrain.URL, []testcase{{ FeedVersionGeometry: []float64{-121.566225, 37.003485, -122.232, 37.486101, -122.386832, 37.599797, -122.412076, 37.631108, -122.394992, 37.77639, -122.394935, 37.776348, -121.650244, 37.129363, -121.610936, 37.086653, -121.610049, 37.085225, -121.566088, 37.003538, -121.566225, 37.003485}, AgencyGeoms: map[string][]float64{ @@ -39,7 +39,7 @@ func TestConvexHullBuilder(t *testing.T) { }}, }, "BART": { - testutil.ExampleFeedBART.URL, + testreader.ExampleFeedBART.URL, []testcase{ { FeedVersionGeometry: []float64{-121.939313, 37.502171, -122.386702, 37.600271, -122.466233, 37.684638, -122.469081, 37.706121, -122.353099, 37.936853, -122.024653, 38.003193, -121.945154, 38.018914, -121.889457, 38.016941, -121.78042, 37.995388, -121.939313, 37.502171}, diff --git a/ext/builders/onestop_id_builder_test.go b/ext/builders/onestop_id_builder_test.go index 91444c1b5..6cea88299 100644 --- a/ext/builders/onestop_id_builder_test.go +++ b/ext/builders/onestop_id_builder_test.go @@ -3,7 +3,7 @@ package builders import ( "testing" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/stretchr/testify/assert" ) @@ -19,7 +19,7 @@ func TestOnestopIDBuilder(t *testing.T) { } groups := map[string]testgroup{ "ExampleFeed": { - testutil.ExampleZip.URL, + testreader.ExampleZip.URL, []testcase{ {"o-9qs-demotransitauthority", hw{"o-9qs-demotransitauthority": []string{"DTA"}}}, {"r-9qsb-20", hw{"r-9qsb-20": []string{"BFC"}}}, @@ -39,7 +39,7 @@ func TestOnestopIDBuilder(t *testing.T) { }, }, "Caltrain": { - testutil.ExampleFeedCaltrain.URL, + testreader.ExampleFeedCaltrain.URL, []testcase{ {"o-9q9-caltrain", hw{"o-9q9-caltrain": []string{"caltrain-ca-us"}}}, {"r-9q9-limited", hw{"r-9q9-limited": []string{"Li-130"}}}, @@ -57,7 +57,7 @@ func TestOnestopIDBuilder(t *testing.T) { }, }, "BART": { - testutil.ExampleFeedBART.URL, + testreader.ExampleFeedBART.URL, []testcase{ {"o-9q9-bayarearapidtransit", hw{"o-9q9-bayarearapidtransit": []string{"BART"}}}, {"r-9q8y-richmond~dalycity~millbrae", hw{"r-9q8y-richmond~dalycity~millbrae": []string{"07"}}}, diff --git a/ext/builders/route_geometry_builder_test.go b/ext/builders/route_geometry_builder_test.go index e65df9a6d..600e13a76 100644 --- a/ext/builders/route_geometry_builder_test.go +++ b/ext/builders/route_geometry_builder_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/interline-io/transitland-lib/internal/testpath" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tlxy" "github.com/stretchr/testify/assert" "github.com/twpayne/go-geom" @@ -34,14 +34,14 @@ func TestRouteGeometryBuilder(t *testing.T) { } groups := map[string]testgroup{ "Caltrain": { - testutil.ExampleFeedCaltrain.URL, + testreader.ExampleFeedCaltrain.URL, []testcase{ {RouteID: "Bu-130", ExpectLength: 75274.982973, ExpectLineStrings: 4}, {RouteID: "Lo-130", ExpectLength: 75274.982973, ExpectLineStrings: 4}, }, }, "BART": { - testutil.ExampleFeedBART.URL, + testreader.ExampleFeedBART.URL, []testcase{ {RouteID: "07", ExpectLength: 58890.123340, ExpectLineStrings: 2}, {RouteID: "03", ExpectLength: 65574.875547, ExpectLineStrings: 2}, diff --git a/ext/builders/route_headway_builder_test.go b/ext/builders/route_headway_builder_test.go index bd3b5bd46..ac22bd57a 100644 --- a/ext/builders/route_headway_builder_test.go +++ b/ext/builders/route_headway_builder_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/interline-io/transitland-lib/internal/testpath" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/stretchr/testify/assert" ) @@ -26,14 +26,14 @@ func TestRouteHeadwayBuilder(t *testing.T) { } groups := map[string]testgroup{ "Caltrain": { - testutil.ExampleFeedCaltrain.URL, + testreader.ExampleFeedCaltrain.URL, []testcase{ {RouteID: "Bu-130", DowCat: 1, DirectionID: 0, StopID: "70011", ServiceDate: "2017-10-02"}, {RouteID: "Lo-130", DowCat: 1, DirectionID: 0, StopID: "70011", ServiceDate: "2017-10-02"}, }, }, "BART": { - testutil.ExampleFeedBART.URL, + testreader.ExampleFeedBART.URL, []testcase{ {RouteID: "07", DowCat: 1, DirectionID: 0, StopID: "12TH", ServiceDate: "2018-05-29", HeadwaySecs: 900}, {RouteID: "07", DowCat: 1, DirectionID: 1, StopID: "12TH", ServiceDate: "2018-05-29", HeadwaySecs: 900}, diff --git a/ext/builders/route_stop_builder_test.go b/ext/builders/route_stop_builder_test.go index b5e90811a..65cf860e7 100644 --- a/ext/builders/route_stop_builder_test.go +++ b/ext/builders/route_stop_builder_test.go @@ -6,7 +6,7 @@ import ( "github.com/interline-io/transitland-lib/adapters/direct" "github.com/interline-io/transitland-lib/copier" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tlcsv" "github.com/stretchr/testify/assert" ) @@ -31,7 +31,7 @@ func newMockCopier(url string, exts ...any) (*copier.Result, *direct.Writer, err func TestRouteStopBuilder(t *testing.T) { e := NewRouteStopBuilder() - _, writer, err := newMockCopier(testutil.ExampleFeedBART.URL, e) + _, writer, err := newMockCopier(testreader.ExampleFeedBART.URL, e) if err != nil { t.Fatal(err) } diff --git a/extract/marker_test.go b/extract/marker_test.go index d07d255ca..6c2747fa3 100644 --- a/extract/marker_test.go +++ b/extract/marker_test.go @@ -5,7 +5,7 @@ import ( "github.com/interline-io/transitland-lib/internal/graph" "github.com/interline-io/transitland-lib/internal/testpath" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tlcsv" ) @@ -18,7 +18,7 @@ func nn(filename, eid string) node { func TestExtract_Filter_BART(t *testing.T) { em := NewMarker() - reader, err := tlcsv.NewReader(testutil.ExampleFeedBART.URL) + reader, err := tlcsv.NewReader(testreader.ExampleFeedBART.URL) if err != nil { t.Error(err) } @@ -41,7 +41,7 @@ func TestExtract_Filter_BART(t *testing.T) { func TestExtract_Bbox(t *testing.T) { em := NewMarker() em.bbox = "-122.276929,37.794923,-122.259099,37.834413" - reader, err := tlcsv.NewReader(testutil.ExampleFeedBART.URL) + reader, err := tlcsv.NewReader(testreader.ExampleFeedBART.URL) if err != nil { t.Error(err) } diff --git a/fetch/static_fetch_test.go b/fetch/static_fetch_test.go index dfdf8553d..966b4d31c 100644 --- a/fetch/static_fetch_test.go +++ b/fetch/static_fetch_test.go @@ -11,12 +11,12 @@ import ( "github.com/interline-io/transitland-lib/dmfr" "github.com/interline-io/transitland-lib/internal/testdb" "github.com/interline-io/transitland-lib/internal/testpath" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tldb" "github.com/stretchr/testify/assert" ) -var ExampleZip = testutil.ExampleZip +var ExampleZip = testreader.ExampleZip func TestStaticFetch(t *testing.T) { basedir := "" diff --git a/filters/redate_filter_test.go b/filters/redate_filter_test.go index e5a7a9973..78293aeed 100644 --- a/filters/redate_filter_test.go +++ b/filters/redate_filter_test.go @@ -7,7 +7,7 @@ import ( "github.com/interline-io/transitland-lib/adapters/direct" "github.com/interline-io/transitland-lib/gtfs" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/service" "github.com/interline-io/transitland-lib/tlcsv" "github.com/interline-io/transitland-lib/tt" @@ -46,7 +46,7 @@ func TestRedateFilter(t *testing.T) { {"no target days", "2018-06-04", "2022-01-03", 7, 0, true, service.Service{}}, {"different weekday", "2018-06-04", "2022-01-04", 7, 7, true, service.Service{}}, } - reader, err := tlcsv.NewReader(testutil.ExampleFeedBART.URL) + reader, err := tlcsv.NewReader(testreader.ExampleFeedBART.URL) if err != nil { t.Fatal(err) } @@ -63,7 +63,7 @@ func TestRedateFilter(t *testing.T) { } // rf.AllowInactive = true w := direct.NewWriter() - cp, err := testutil.NewDirectCopier(reader, w, testutil.DirectCopierOptions{}) + cp, err := testreader.NewDirectCopier(reader, w, testreader.DirectCopierOptions{}) if err != nil { t.Fatal(err) } diff --git a/gtfs/gtfs_test.go b/gtfs/gtfs_test.go index e3c060095..82d533e5d 100644 --- a/gtfs/gtfs_test.go +++ b/gtfs/gtfs_test.go @@ -12,7 +12,7 @@ import ( // Test helpers - local implementations to avoid import cycle with internal/testutil -// ExpectError describes an expected validation error (matches testutil.ExpectError interface) +// ExpectError describes an expected validation error (matches testreader.ExpectError interface) type ExpectError struct { Field string ErrorType string diff --git a/importer/importer_test.go b/importer/importer_test.go index 209dc0c01..2b0ce8681 100644 --- a/importer/importer_test.go +++ b/importer/importer_test.go @@ -8,7 +8,7 @@ import ( "github.com/interline-io/transitland-lib/dmfr" "github.com/interline-io/transitland-lib/internal/testdb" "github.com/interline-io/transitland-lib/internal/testpath" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tldb" "github.com/interline-io/transitland-lib/tt" ) @@ -25,7 +25,7 @@ func TestImportFeedVersion(t *testing.T) { } t.Run("Success", func(t *testing.T) { testdb.TempSqlite(func(atx tldb.Adapter) error { - fvid := setup(atx, testutil.ExampleZip.URL) + fvid := setup(atx, testreader.ExampleZip.URL) atx2 := testdb.AdapterIgnoreTx{Adapter: atx} _, err := ImportFeedVersion(ctx, &atx2, Options{Activate: true, FeedVersionID: fvid, Storage: "/"}) if err != nil { @@ -44,7 +44,7 @@ func TestImportFeedVersion(t *testing.T) { t.Errorf("expected in_progress = false") } count := 0 - expstops := testutil.ExampleZip.Counts["stops.txt"] + expstops := testreader.ExampleZip.Counts["stops.txt"] testdb.ShouldGet(t, atx, &count, "SELECT count(*) FROM gtfs_stops WHERE feed_version_id = ?", fvid) if count != expstops { t.Errorf("got %d stops, expect %d stops", count, expstops) @@ -88,7 +88,7 @@ func Test_iImportFeedVersionTx(t *testing.T) { ctx := context.TODO() err := testdb.TempSqlite(func(atx tldb.Adapter) error { // Create FV - fv := dmfr.FeedVersion{File: testutil.ExampleZip.URL} + fv := dmfr.FeedVersion{File: testreader.ExampleZip.URL} fv.EarliestCalendarDate = tt.NewDate(time.Now()) fv.LatestCalendarDate = tt.NewDate(time.Now()) fvid := testdb.ShouldInsert(t, atx, &fv) @@ -100,7 +100,7 @@ func Test_iImportFeedVersionTx(t *testing.T) { } // Check count := 0 - expstops := testutil.ExampleZip.Counts["stops.txt"] + expstops := testreader.ExampleZip.Counts["stops.txt"] testdb.ShouldGet(t, atx, &count, "SELECT count(*) FROM gtfs_stops WHERE feed_version_id = ?", fvid) if count != expstops { t.Errorf("expect %d stops, got %d", count, expstops) diff --git a/importer/unimporter_test.go b/importer/unimporter_test.go index 72c8e2f1e..665e2e633 100644 --- a/importer/unimporter_test.go +++ b/importer/unimporter_test.go @@ -10,7 +10,7 @@ import ( "github.com/interline-io/log" "github.com/interline-io/transitland-lib/dmfr" "github.com/interline-io/transitland-lib/internal/testdb" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/stats" "github.com/interline-io/transitland-lib/tlcsv" "github.com/interline-io/transitland-lib/tldb" @@ -33,14 +33,14 @@ func setupImport(ctx context.Context, t *testing.T, atx tldb.Adapter) int { feed := dmfr.Feed{} feed.FeedID = fmt.Sprintf("feed-%d", time.Now().UnixNano()) feedid := testdb.ShouldInsert(t, atx, &feed) - fv := dmfr.FeedVersion{File: testutil.ExampleZip.URL} + fv := dmfr.FeedVersion{File: testreader.ExampleZip.URL} fv.FeedID = feedid fv.EarliestCalendarDate = tt.NewDate(time.Now()) fv.LatestCalendarDate = tt.NewDate(time.Now()) fvid := testdb.ShouldInsert(t, atx, &fv) fv.ID = fvid // Generate stats - tlreader, err := tlcsv.NewReader(testutil.ExampleZip.URL) + tlreader, err := tlcsv.NewReader(testreader.ExampleZip.URL) if err != nil { t.Fatal(err) } diff --git a/internal/feedstate/manager_test.go b/internal/feedstate/manager_test.go index 622840976..6e2cdc4d4 100644 --- a/internal/feedstate/manager_test.go +++ b/internal/feedstate/manager_test.go @@ -6,7 +6,7 @@ import ( "time" "github.com/interline-io/transitland-lib/copier" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/server/dbutil" "github.com/interline-io/transitland-lib/tlcsv" "github.com/interline-io/transitland-lib/tldb" @@ -135,7 +135,7 @@ func verifyFeedState(t *testing.T, adapter tldb.Adapter, feedID int, expectedFee } func TestManager_ActivateFeedVersion(t *testing.T) { - adapter, feedID, feedVersionID := setupTestDB(t, testFeedOnestopID, testutil.ExampleZip.URL) + adapter, feedID, feedVersionID := setupTestDB(t, testFeedOnestopID, testreader.ExampleZip.URL) defer adapter.Close() manager := NewManager(adapter) @@ -157,7 +157,7 @@ func TestManager_ActivateFeedVersion(t *testing.T) { } func TestManager_DeactivateFeedVersion(t *testing.T) { - adapter, feedID, feedVersionID := setupTestDB(t, testFeedOnestopID, testutil.ExampleZip.URL) + adapter, feedID, feedVersionID := setupTestDB(t, testFeedOnestopID, testreader.ExampleZip.URL) defer adapter.Close() manager := NewManager(adapter) @@ -190,7 +190,7 @@ func TestManager_DeactivateFeedVersion(t *testing.T) { } func TestManager_GetActiveFeedVersions(t *testing.T) { - adapter, _, feedVersionID := setupTestDB(t, testFeedOnestopID, testutil.ExampleZip.URL) + adapter, _, feedVersionID := setupTestDB(t, testFeedOnestopID, testreader.ExampleZip.URL) defer adapter.Close() manager := NewManager(adapter) @@ -213,7 +213,7 @@ func TestManager_GetActiveFeedVersions(t *testing.T) { } func TestManager_SetActiveFeedVersions(t *testing.T) { - adapter, _, feedVersionID := setupTestDB(t, testFeedOnestopID, testutil.ExampleZip.URL) + adapter, _, feedVersionID := setupTestDB(t, testFeedOnestopID, testreader.ExampleZip.URL) defer adapter.Close() manager := NewManager(adapter) @@ -249,7 +249,7 @@ func TestManager_SetActiveFeedVersions(t *testing.T) { feedVersionID2 := feedVersionID + 1 // Import data for second feed (copy the same GTFS data with different feed_id) - reader, err := tlcsv.NewReader(testutil.ExampleFeedCaltrain.URL) + reader, err := tlcsv.NewReader(testreader.ExampleFeedCaltrain.URL) require.NoError(t, err, "failed to create GTFS reader for second feed") writer, err := tldb.NewWriter("sqlite3://:memory:") @@ -300,7 +300,7 @@ func TestManager_SetActiveFeedVersions(t *testing.T) { } func TestManager_MaterializedDataIntegrity(t *testing.T) { - adapter, _, feedVersionID := setupTestDB(t, testFeedOnestopID, testutil.ExampleZip.URL) + adapter, _, feedVersionID := setupTestDB(t, testFeedOnestopID, testreader.ExampleZip.URL) defer adapter.Close() manager := NewManager(adapter) @@ -375,7 +375,7 @@ func TestManager_MaterializedDataIntegrity(t *testing.T) { } func TestManager_GetFeedIDForFeedVersion(t *testing.T) { - adapter, feedID, feedVersionID := setupTestDB(t, testFeedOnestopID, testutil.ExampleZip.URL) + adapter, feedID, feedVersionID := setupTestDB(t, testFeedOnestopID, testreader.ExampleZip.URL) defer adapter.Close() manager := NewManager(adapter) diff --git a/internal/geomcache/geomcache_test.go b/internal/geomcache/geomcache_test.go index f2d503c7b..bf63ad33a 100644 --- a/internal/geomcache/geomcache_test.go +++ b/internal/geomcache/geomcache_test.go @@ -4,13 +4,13 @@ import ( "testing" "github.com/interline-io/transitland-lib/gtfs" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/service" "github.com/interline-io/transitland-lib/tlcsv" ) func TestGeomCache(t *testing.T) { - r, err := tlcsv.NewReader(testutil.ExampleDir.URL) + r, err := tlcsv.NewReader(testreader.ExampleDir.URL) if err != nil { t.Error(err) } diff --git a/internal/testutil/copy.go b/internal/testreader/copy.go similarity index 98% rename from internal/testutil/copy.go rename to internal/testreader/copy.go index 9a6fb82c3..235c00eee 100644 --- a/internal/testutil/copy.go +++ b/internal/testreader/copy.go @@ -1,4 +1,4 @@ -package testutil +package testreader import ( "fmt" diff --git a/internal/testutil/copy_test.go b/internal/testreader/copy_test.go similarity index 97% rename from internal/testutil/copy_test.go rename to internal/testreader/copy_test.go index 2c40f9a5d..e97249858 100644 --- a/internal/testutil/copy_test.go +++ b/internal/testreader/copy_test.go @@ -1,4 +1,4 @@ -package testutil +package testreader import ( "testing" diff --git a/internal/testutil/entities.go b/internal/testreader/entities.go similarity index 99% rename from internal/testutil/entities.go rename to internal/testreader/entities.go index d7bf50b73..fb6d3ff0f 100644 --- a/internal/testutil/entities.go +++ b/internal/testreader/entities.go @@ -1,4 +1,4 @@ -package testutil +package testreader import ( "github.com/interline-io/transitland-lib/adapters" diff --git a/internal/testutil/minimal.go b/internal/testreader/minimal.go similarity index 99% rename from internal/testutil/minimal.go rename to internal/testreader/minimal.go index 8bf24aed9..4f8ffa7cf 100644 --- a/internal/testutil/minimal.go +++ b/internal/testreader/minimal.go @@ -1,4 +1,4 @@ -package testutil +package testreader import ( "time" diff --git a/internal/testutil/readertester.go b/internal/testreader/readertester.go similarity index 99% rename from internal/testutil/readertester.go rename to internal/testreader/readertester.go index a3b46f544..b395944bf 100644 --- a/internal/testutil/readertester.go +++ b/internal/testreader/readertester.go @@ -1,4 +1,4 @@ -package testutil +package testreader import ( "testing" diff --git a/internal/testutil/test_feeds.go b/internal/testreader/test_feeds.go similarity index 99% rename from internal/testutil/test_feeds.go rename to internal/testreader/test_feeds.go index a1781a964..5859fe78d 100644 --- a/internal/testutil/test_feeds.go +++ b/internal/testreader/test_feeds.go @@ -1,4 +1,4 @@ -package testutil +package testreader import "github.com/interline-io/transitland-lib/internal/testpath" diff --git a/service/tests/service_test.go b/service/tests/service_test.go index 9248610b4..5fe254971 100644 --- a/service/tests/service_test.go +++ b/service/tests/service_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/interline-io/transitland-lib/gtfs" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/service" "github.com/interline-io/transitland-lib/tlcsv" "github.com/interline-io/transitland-lib/tt" @@ -78,7 +78,7 @@ func TestService_Simplify(t *testing.T) { } // get more examples from feeds feedchecks := []string{} - for _, v := range testutil.ExternalTestFeeds { + for _, v := range testreader.ExternalTestFeeds { feedchecks = append(feedchecks, v.URL) } for _, path := range feedchecks { diff --git a/stats/fvfi_test.go b/stats/fvfi_test.go index 9c433fee7..18b58987d 100644 --- a/stats/fvfi_test.go +++ b/stats/fvfi_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/interline-io/transitland-lib/dmfr" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tlcsv" "github.com/interline-io/transitland-lib/tt" ) @@ -17,7 +17,7 @@ func TestNewFeedVersionFileInfosFromReader(t *testing.T) { }{ { "example", - testutil.ExampleZip.URL, + testreader.ExampleZip.URL, []dmfr.FeedVersionFileInfo{ { Name: "agency.txt", @@ -266,7 +266,7 @@ func TestNewFeedVersionFileInfosFromReader(t *testing.T) { }, { "bart", - testutil.ExampleFeedBART.URL, + testreader.ExampleFeedBART.URL, []dmfr.FeedVersionFileInfo{ { Name: "agency.txt", diff --git a/stats/fvsl_test.go b/stats/fvsl_test.go index ee53781ef..8953a30cb 100644 --- a/stats/fvsl_test.go +++ b/stats/fvsl_test.go @@ -7,7 +7,7 @@ import ( "time" "github.com/interline-io/transitland-lib/dmfr" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tlcsv" "github.com/interline-io/transitland-lib/tt" "github.com/stretchr/testify/assert" @@ -39,13 +39,13 @@ func TestNewFeedVersionServiceLevelsFromReader(t *testing.T) { }{ { "example", - testutil.ExampleZip.URL, + testreader.ExampleZip.URL, msi{"CITY": 4, "AB": 4, "STBA": 4, "": 4}, []string{}, }, { "bart", - testutil.ExampleFeedBART.URL, + testreader.ExampleFeedBART.URL, msi{"01": 12, "11": 12, "03": 12}, []string{ // feed diff --git a/stats/fvsw_test.go b/stats/fvsw_test.go index 108a9e78c..cc5b78984 100644 --- a/stats/fvsw_test.go +++ b/stats/fvsw_test.go @@ -3,7 +3,7 @@ package stats import ( "testing" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tlcsv" "github.com/interline-io/transitland-lib/tt" "github.com/stretchr/testify/assert" @@ -20,7 +20,7 @@ func TestNewFeedVersionServiceWindowsFromReader(t *testing.T) { }{ { "example", - testutil.ExampleZip.URL, + testreader.ExampleZip.URL, pd(""), pd(""), pd("2007-01-01"), @@ -28,7 +28,7 @@ func TestNewFeedVersionServiceWindowsFromReader(t *testing.T) { }, { "bart", - testutil.ExampleFeedBART.URL, + testreader.ExampleFeedBART.URL, pd("2018-05-26"), pd("2019-07-01"), pd("2018-06-04"), @@ -36,7 +36,7 @@ func TestNewFeedVersionServiceWindowsFromReader(t *testing.T) { }, { "caltrain", - testutil.ExampleFeedCaltrain.URL, + testreader.ExampleFeedCaltrain.URL, pd(""), pd(""), pd("2018-06-18"), diff --git a/tlcsv/adapter_test.go b/tlcsv/adapter_test.go index 3c38147d5..f75c3777a 100644 --- a/tlcsv/adapter_test.go +++ b/tlcsv/adapter_test.go @@ -9,17 +9,17 @@ import ( "github.com/interline-io/transitland-lib/gtfs" "github.com/interline-io/transitland-lib/internal/testpath" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" ) func getTestAdapters() map[string]func() Adapter { adapters := map[string]func() Adapter{ - "DirAdapter": func() Adapter { return NewDirAdapter(testutil.ExampleDir.URL) }, - "ZipAdapter": func() Adapter { return NewZipAdapter(testutil.ExampleZip.URL) }, - "ZipAdapterNestedDir": func() Adapter { return NewZipAdapter(testutil.ExampleZipNestedDir.URL) }, - "ZipAdapterNestedTwoFeeds": func() Adapter { return NewZipAdapter(testutil.ExampleZipNestedTwoFeeds2.URL) }, - "ZipAdapterNestedZip": func() Adapter { return NewZipAdapter(testutil.ExampleZipNestedZip.URL) }, - "OverlayAdapter": func() Adapter { return NewOverlayAdapter(testutil.ExampleDir.URL) }, + "DirAdapter": func() Adapter { return NewDirAdapter(testreader.ExampleDir.URL) }, + "ZipAdapter": func() Adapter { return NewZipAdapter(testreader.ExampleZip.URL) }, + "ZipAdapterNestedDir": func() Adapter { return NewZipAdapter(testreader.ExampleZipNestedDir.URL) }, + "ZipAdapterNestedTwoFeeds": func() Adapter { return NewZipAdapter(testreader.ExampleZipNestedTwoFeeds2.URL) }, + "ZipAdapterNestedZip": func() Adapter { return NewZipAdapter(testreader.ExampleZipNestedZip.URL) }, + "OverlayAdapter": func() Adapter { return NewOverlayAdapter(testreader.ExampleDir.URL) }, } return adapters } @@ -40,8 +40,8 @@ func TestDirAdapter(t *testing.T) { if err != nil { t.Error(err) } - if s != testutil.ExampleDir.DirSHA1 { - t.Errorf("got %s expect %s", s, testutil.ExampleDir.DirSHA1) + if s != testreader.ExampleDir.DirSHA1 { + t.Errorf("got %s expect %s", s, testreader.ExampleDir.DirSHA1) } }) } @@ -70,8 +70,8 @@ func TestZipAdapter(t *testing.T) { if err != nil { t.Error(err) } - if s != testutil.ExampleZip.SHA1 { - t.Errorf("got %s expect %s", s, testutil.ExampleZip.SHA1) + if s != testreader.ExampleZip.SHA1 { + t.Errorf("got %s expect %s", s, testreader.ExampleZip.SHA1) } }) t.Run("DirSHA1", func(t *testing.T) { @@ -84,8 +84,8 @@ func TestZipAdapter(t *testing.T) { if err != nil { t.Error(err) } - if s != testutil.ExampleZip.DirSHA1 { - t.Errorf("got %s expect %s", s, testutil.ExampleZip.DirSHA1) + if s != testreader.ExampleZip.DirSHA1 { + t.Errorf("got %s expect %s", s, testreader.ExampleZip.DirSHA1) } }) } @@ -169,7 +169,7 @@ func TestZipAdapterNestedZip(t *testing.T) { func TestURLAdapter(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - buf, err := os.ReadFile(testutil.ExampleZip.URL) + buf, err := os.ReadFile(testreader.ExampleZip.URL) if err != nil { t.Error(err) } diff --git a/tlcsv/reader_benchmark_test.go b/tlcsv/reader_benchmark_test.go index 1b235d8bd..c961a8ddd 100644 --- a/tlcsv/reader_benchmark_test.go +++ b/tlcsv/reader_benchmark_test.go @@ -3,12 +3,12 @@ package tlcsv import ( "testing" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" ) func BenchmarkReader(b *testing.B) { b.SetParallelism(1) - for k, fe := range testutil.ExternalTestFeeds { + for k, fe := range testreader.ExternalTestFeeds { b.Run(k, func(b *testing.B) { for n := 0; n < b.N; n++ { reader, err := NewReader(fe.URL) @@ -18,7 +18,7 @@ func BenchmarkReader(b *testing.B) { if err := reader.Open(); err != nil { b.Error(err) } - testutil.CheckReader(b, fe, reader) + testreader.CheckReader(b, fe, reader) if err := reader.Close(); err != nil { b.Error(err) } diff --git a/tlcsv/reader_test.go b/tlcsv/reader_test.go index 146bfd2eb..c533e50d6 100644 --- a/tlcsv/reader_test.go +++ b/tlcsv/reader_test.go @@ -7,13 +7,13 @@ import ( "testing" "github.com/interline-io/transitland-lib/adapters" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" ) func TestReader(t *testing.T) { // Start local HTTP server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - buf, err := os.ReadFile(testutil.ExampleZip.URL) + buf, err := os.ReadFile(testreader.ExampleZip.URL) if err != nil { t.Error(err) } @@ -25,7 +25,7 @@ func TestReader(t *testing.T) { tsa["URL"] = func() Adapter { return &URLAdapter{url: ts.URL} } for k, v := range tsa { t.Run(k, func(t *testing.T) { - testutil.TestReader(t, testutil.ExampleDir, func() adapters.Reader { + testreader.TestReader(t, testreader.ExampleDir, func() adapters.Reader { return &Reader{Adapter: v()} }) }) diff --git a/tlcsv/writer_benchmark_test.go b/tlcsv/writer_benchmark_test.go index 248a75b49..fcee6cd41 100644 --- a/tlcsv/writer_benchmark_test.go +++ b/tlcsv/writer_benchmark_test.go @@ -5,12 +5,12 @@ import ( "testing" "github.com/interline-io/transitland-lib/adapters" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" ) func BenchmarkWriter(b *testing.B) { b.SetParallelism(1) - for k, fe := range testutil.ExternalTestFeeds { + for k, fe := range testreader.ExternalTestFeeds { b.Run(k, func(b *testing.B) { for i := 0; i < b.N; i++ { tmpdir, err := os.MkdirTemp("", "gtfs") @@ -22,7 +22,7 @@ func BenchmarkWriter(b *testing.B) { if err != nil { b.Error(err) } - testutil.TestWriter(b, fe, func() adapters.Reader { + testreader.TestWriter(b, fe, func() adapters.Reader { a, err := NewReader(fe.URL) if err != nil { b.Error(err) diff --git a/tlcsv/writer_test.go b/tlcsv/writer_test.go index 0f64dcc0f..3886cd1bb 100644 --- a/tlcsv/writer_test.go +++ b/tlcsv/writer_test.go @@ -6,13 +6,13 @@ import ( "github.com/interline-io/transitland-lib/adapters" "github.com/interline-io/transitland-lib/gtfs" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/stretchr/testify/assert" ) // Round trip Writer test. func TestWriter(t *testing.T) { - fe, reader := testutil.NewMinimalTestFeed() + fe, reader := testreader.NewMinimalTestFeed() tmpdir, err := os.MkdirTemp("", "gtfs") if err != nil { t.Error(err) @@ -21,7 +21,7 @@ func TestWriter(t *testing.T) { if err != nil { t.Error(err) } - testutil.TestWriter(t, *fe, func() adapters.Reader { return reader }, func() adapters.Writer { return writer }) + testreader.TestWriter(t, *fe, func() adapters.Reader { return reader }, func() adapters.Writer { return writer }) // Clean up and double check if err := os.RemoveAll(tmpdir); err != nil { t.Error(err) diff --git a/tldb/tldbtest/adapter_benchmark.go b/tldb/tldbtest/adapter_benchmark.go index 729bff756..c0e8c2fb9 100644 --- a/tldb/tldbtest/adapter_benchmark.go +++ b/tldb/tldbtest/adapter_benchmark.go @@ -9,7 +9,7 @@ import ( "github.com/interline-io/transitland-lib/dmfr" "github.com/interline-io/transitland-lib/gtfs" - "github.com/interline-io/transitland-lib/internal/testutil" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/tldb" ) @@ -98,11 +98,11 @@ func Benchmark_Adapter_MultiInsert(b *testing.B) { } // Load the minimal test feed... writer := tldb.Writer{Adapter: adapter} - _, reader := testutil.NewMinimalTestFeed() + _, reader := testreader.NewMinimalTestFeed() if err := reader.Open(); err != nil { b.Error(err) } - if err := testutil.DirectCopy(reader, &writer); err != nil { + if err := testreader.DirectCopy(reader, &writer); err != nil { b.Error(err) } // get ids diff --git a/validator/validator_test.go b/validator/validator_test.go index 5fb734254..99bf84ed9 100644 --- a/validator/validator_test.go +++ b/validator/validator_test.go @@ -12,6 +12,7 @@ import ( "github.com/interline-io/transitland-lib/internal/testdb" "github.com/interline-io/transitland-lib/internal/testpath" + "github.com/interline-io/transitland-lib/internal/testreader" "github.com/interline-io/transitland-lib/internal/testutil" "github.com/interline-io/transitland-lib/tlcsv" "github.com/interline-io/transitland-lib/tldb" @@ -67,7 +68,7 @@ func TestEntityErrors(t *testing.T) { if err := reader.Open(); err != nil { t.Error(err) } - testutil.AllEntities(reader, func(ent tt.Entity) { + testreader.AllEntities(reader, func(ent tt.Entity) { t.Run(fmt.Sprintf("%s:%s", ent.Filename(), ent.EntityID()), func(t *testing.T) { errs := tt.CheckErrors(ent) expecterrs := testutil.GetExpectErrors(ent) From dbc1b40c92f08606d106eb0a681c0528a1d40baa Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 16:33:53 -0800 Subject: [PATCH 16/17] Use testutil --- gtfs/gtfs_test.go | 74 +++++------------------------------------------ 1 file changed, 8 insertions(+), 66 deletions(-) diff --git a/gtfs/gtfs_test.go b/gtfs/gtfs_test.go index 82d533e5d..6542a51e7 100644 --- a/gtfs/gtfs_test.go +++ b/gtfs/gtfs_test.go @@ -2,85 +2,27 @@ package gtfs import ( "fmt" - "strings" "testing" + "github.com/interline-io/transitland-lib/internal/testutil" "github.com/interline-io/transitland-lib/tt" ) ///////////////////////// -// Test helpers - local implementations to avoid import cycle with internal/testutil +// Test helpers - wrappers to avoid import cycle with internal/testutil -// ExpectError describes an expected validation error (matches testreader.ExpectError interface) -type ExpectError struct { - Field string - ErrorType string - Filename string - EntityID string -} +// ExpectError is an alias to testutil.ExpectError +type ExpectError = testutil.ExpectError -// ParseExpectErrors converts shorthand error strings to ExpectError structs -// Format: "ErrorType:Field:Filename:EntityID" (Filename and EntityID are optional) +// ParseExpectErrors wraps testutil.ParseExpectErrors func ParseExpectErrors(errorStrings ...string) []ExpectError { - var errors []ExpectError - for _, s := range errorStrings { - parts := strings.Split(s, ":") - // Pad to 4 parts - for len(parts) < 4 { - parts = append(parts, "") - } - errors = append(errors, ExpectError{ - ErrorType: parts[0], - Field: parts[1], - Filename: parts[2], - EntityID: parts[3], - }) - } - return errors + return testutil.ParseExpectErrors(errorStrings...) } -// CheckErrors validates that actual errors match expected errors +// CheckErrors wraps testutil.CheckErrors func CheckErrors(expectedErrors []ExpectError, errs []error, t *testing.T) { - t.Helper() - - if len(expectedErrors) == 0 && len(errs) == 0 { - return - } - - if len(expectedErrors) != len(errs) { - t.Errorf("Expected %d errors, got %d", len(expectedErrors), len(errs)) - for _, err := range errs { - t.Logf(" Actual error: %v", err) - } - return - } - - // Check that each expected error matches an actual error - for i, expected := range expectedErrors { - if i >= len(errs) { - break - } - errStr := errs[i].Error() - errType := fmt.Sprintf("%T", errs[i]) - - // Extract just the type name without package path - if idx := strings.LastIndex(errType, "."); idx >= 0 { - errType = errType[idx+1:] - } - // Remove pointer marker - errType = strings.TrimPrefix(errType, "*") - - // Check that error string contains the expected field - if expected.Field != "" && !strings.Contains(errStr, expected.Field) { - t.Errorf("Error %d: expected field %q not found in error: %v", i, expected.Field, errStr) - } - - // Check error type matches - if expected.ErrorType != "" && errType != expected.ErrorType { - t.Errorf("Error %d: expected error type %q, got %q in error: %v", i, expected.ErrorType, errType, errStr) - } - } + testutil.CheckErrors(expectedErrors, errs, t) } ///////////////////////// From 860dc524c7e0559120da4345b4e099d5f798432d Mon Sep 17 00:00:00 2001 From: Ian Rees Date: Wed, 3 Dec 2025 16:56:55 -0800 Subject: [PATCH 17/17] Skip stop times without stop_id --- copier/copier.go | 3 +++ copier/stoppattern.go | 12 +++++++++--- ext/builders/agency_place_builder.go | 3 +++ ext/builders/convex_hull_builder.go | 3 +++ ext/builders/onestop_id_builder.go | 3 +++ ext/builders/route_headway_builder.go | 3 +++ ext/builders/route_stop_builder.go | 3 +++ gtfs/gtfs_test.go | 2 -- gtfs/location.go | 15 ++++----------- gtfs/stop_time.go | 2 -- internal/geomcache/geomcache.go | 7 +++++-- rules/fast_travel.go | 6 ++++++ rules/flex_stop_location_type.go | 2 +- rules/too_far_from_shape.go | 3 +++ 14 files changed, 46 insertions(+), 21 deletions(-) diff --git a/copier/copier.go b/copier/copier.go index 07feaf6ef..51da0c802 100644 --- a/copier/copier.go +++ b/copier/copier.go @@ -1025,6 +1025,9 @@ func (copier *Copier) logCount(ent tt.Entity) { func (copier *Copier) createMissingShape(shapeID string, stoptimes []gtfs.StopTime) (string, error) { stopids := []string{} for _, st := range stoptimes { + if !st.StopID.Valid { + continue + } stopids = append(stopids, st.StopID.Val) } line, dists, err := copier.geomCache.MakeShape(stopids...) diff --git a/copier/stoppattern.go b/copier/stoppattern.go index 6ab70476f..cb89c2e0d 100644 --- a/copier/stoppattern.go +++ b/copier/stoppattern.go @@ -9,9 +9,11 @@ import ( ) func stopPatternKey(stoptimes []gtfs.StopTime) string { - key := make([]string, len(stoptimes)) + key := make([]string, 0, len(stoptimes)) for i := 0; i < len(stoptimes); i++ { - key[i] = stoptimes[i].StopID.Val + if stoptimes[i].StopID.Valid { + key = append(key, stoptimes[i].StopID.Val) + } } return strings.Join(key, string(byte(0))) } @@ -34,11 +36,15 @@ func journeyPatternKey(trip *gtfs.Trip) string { ))) for i := 0; i < len(trip.StopTimes); i++ { st := trip.StopTimes[i] + stopID := "" + if st.StopID.Valid { + stopID = st.StopID.Val + } m.Write([]byte(fmt.Sprintf( "%d-%d-%s-%s-%d-%d-%d", st.ArrivalTime.Val-a.Val, st.DepartureTime.Val-b.Val, - st.StopID.Val, + stopID, st.StopHeadsign.Val, st.PickupType.Val, st.DropOffType.Val, diff --git a/ext/builders/agency_place_builder.go b/ext/builders/agency_place_builder.go index a67ac9bec..8dc4f00d4 100644 --- a/ext/builders/agency_place_builder.go +++ b/ext/builders/agency_place_builder.go @@ -62,6 +62,9 @@ func (pp *AgencyPlaceBuilder) AfterWrite(eid string, ent tt.Entity, emap *tt.Ent case *gtfs.Trip: pp.tripAgency[eid] = pp.routeAgency[v.RouteID.Val] case *gtfs.StopTime: + if !v.StopID.Valid { + return nil + } aid := pp.tripAgency[v.TripID.Val] if sg, ok := pp.stops[v.StopID.Val]; ok { if v, ok := pp.agencyStops[aid]; ok { diff --git a/ext/builders/convex_hull_builder.go b/ext/builders/convex_hull_builder.go index 525bdc528..f1a94232b 100644 --- a/ext/builders/convex_hull_builder.go +++ b/ext/builders/convex_hull_builder.go @@ -73,6 +73,9 @@ func (pp *ConvexHullBuilder) AfterWrite(eid string, ent tt.Entity, emap *tt.Enti case *gtfs.Trip: pp.tripRoutes[eid] = v.RouteID.Val case *gtfs.StopTime: + if !v.StopID.Valid { + return nil + } r, ok := pp.routeStopGeoms[pp.tripRoutes[v.TripID.Val]] if !ok { // log.For(ctx).Debug().Msgf("no route:", v.TripID, pp.tripRoutes[v.TripID]) diff --git a/ext/builders/onestop_id_builder.go b/ext/builders/onestop_id_builder.go index 1a49a0f0a..136ad60f6 100644 --- a/ext/builders/onestop_id_builder.go +++ b/ext/builders/onestop_id_builder.go @@ -94,6 +94,9 @@ func (pp *OnestopIDBuilder) AfterWrite(eid string, ent tt.Entity, emap *tt.Entit case *gtfs.Trip: pp.tripRoutes[eid] = v.RouteID.Val case *gtfs.StopTime: + if !v.StopID.Valid { + return nil + } r, ok := pp.routeStopGeoms[pp.tripRoutes[v.TripID.Val]] if !ok { // log.For(ctx).Debug().Msgf("OnestopIDBuilder no route:", v.TripID, pp.tripRoutes[v.TripID]) diff --git a/ext/builders/route_headway_builder.go b/ext/builders/route_headway_builder.go index 94d26c815..f50292d7f 100644 --- a/ext/builders/route_headway_builder.go +++ b/ext/builders/route_headway_builder.go @@ -81,6 +81,9 @@ func (pp *RouteHeadwayBuilder) AfterWrite(eid string, ent tt.Entity, emap *tt.En // Process StopTimes assuming they will all be written // otherwise this breaks on journey pattern deduplication. for _, st := range v.StopTimes { + if !st.StopID.Valid { + continue + } stopId, ok := emap.Get("stops.txt", st.StopID.Val) if !ok { continue diff --git a/ext/builders/route_stop_builder.go b/ext/builders/route_stop_builder.go index 1608ee643..ce470c385 100644 --- a/ext/builders/route_stop_builder.go +++ b/ext/builders/route_stop_builder.go @@ -45,6 +45,9 @@ func (pp *RouteStopBuilder) AfterWrite(eid string, ent tt.Entity, emap *tt.Entit case *gtfs.Trip: pp.tripRoutes[eid] = v.RouteID.Val case *gtfs.StopTime: + if !v.StopID.Valid { + return nil + } rid := pp.tripRoutes[v.TripID.Val] rs, ok := pp.routeStops[rid] if !ok { diff --git a/gtfs/gtfs_test.go b/gtfs/gtfs_test.go index 6542a51e7..cca7d721b 100644 --- a/gtfs/gtfs_test.go +++ b/gtfs/gtfs_test.go @@ -10,8 +10,6 @@ import ( ///////////////////////// -// Test helpers - wrappers to avoid import cycle with internal/testutil - // ExpectError is an alias to testutil.ExpectError type ExpectError = testutil.ExpectError diff --git a/gtfs/location.go b/gtfs/location.go index 13544b7b2..2865e0b44 100644 --- a/gtfs/location.go +++ b/gtfs/location.go @@ -12,12 +12,10 @@ type Location struct { // ID is the feature ID from the GeoJSON, shares namespace with stop_id LocationID tt.String `json:"id" csv:",required"` // Properties - StopName tt.String `json:"stop_name"` - StopDesc tt.String `json:"stop_desc"` - ZoneID tt.String `json:"zone_id"` - StopURL tt.Url `json:"stop_url"` - // Geometry - can be either Polygon or MultiPolygon - // We store the raw geometry as a Geometry type which can hold either + StopName tt.String `json:"stop_name"` + StopDesc tt.String `json:"stop_desc"` + ZoneID tt.String `json:"zone_id"` + StopURL tt.Url `json:"stop_url"` Geometry tt.Geometry `json:"geometry"` tt.BaseEntity } @@ -40,10 +38,6 @@ func (ent *Location) TableName() string { // ConditionalErrors for this Entity. func (ent *Location) ConditionalErrors() (errs []error) { - // zone_id is conditionally required if fare_rules.txt is defined - // This check would need to be done at a higher level since we don't have - // access to the full feed context here - // Geometry must be present if !ent.Geometry.Valid { errs = append(errs, causes.NewConditionallyRequiredFieldError("geometry")) @@ -51,4 +45,3 @@ func (ent *Location) ConditionalErrors() (errs []error) { return errs } - diff --git a/gtfs/stop_time.go b/gtfs/stop_time.go index 81387737e..ab5a53963 100644 --- a/gtfs/stop_time.go +++ b/gtfs/stop_time.go @@ -56,8 +56,6 @@ func (ent *StopTime) Errors() []error { // Don't use reflection based path errs := []error{} errs = append(errs, tt.CheckPresent("trip_id", ent.TripID.Val)...) - // Note: stop_id is conditionally required - validated in ConditionalErrors() - // It's required if location_group_id AND location_id are NOT defined errs = append(errs, tt.CheckPositiveInt("stop_sequence", ent.StopSequence.Val)...) errs = append(errs, tt.CheckInsideRangeInt("pickup_type", ent.PickupType.Val, 0, 3)...) errs = append(errs, tt.CheckInsideRangeInt("drop_off_type", ent.DropOffType.Val, 0, 3)...) diff --git a/internal/geomcache/geomcache.go b/internal/geomcache/geomcache.go index bea22d3c1..3f5bfb405 100644 --- a/internal/geomcache/geomcache.go +++ b/internal/geomcache/geomcache.go @@ -140,13 +140,16 @@ func (g *GeomCache) setStopTimeDists(shapeId string, patternId int64, sts []gtfs stopPositionInfo, ok := g.stopPositions[stopPositionsKey] if !ok { // Generate the stop-to-stop geometry - stopLine := make([]tlxy.Point, len(sts)) + stopLine := make([]tlxy.Point, 0, len(sts)) for i := 0; i < len(sts); i++ { + if !sts[i].StopID.Valid { + continue + } point, ok := g.stops[sts[i].StopID.Val] if !ok { return fmt.Errorf("stop '%s' not in cache", sts[i].StopID) } - stopLine[i] = point + stopLine = append(stopLine, point) } // Get the known shape line and known shape distance diff --git a/rules/fast_travel.go b/rules/fast_travel.go index 5fc28e78d..4ae7e340e 100644 --- a/rules/fast_travel.go +++ b/rules/fast_travel.go @@ -97,9 +97,15 @@ func (e *StopTimeFastTravelCheck) Validate(ent tt.Entity) []error { } // todo: cache for trip pattern? var errs []error + if len(trip.StopTimes) == 0 || !trip.StopTimes[0].StopID.Valid { + return errs + } s1 := trip.StopTimes[0].StopID.Val t := trip.StopTimes[0].DepartureTime for i := 1; i < len(trip.StopTimes); i++ { + if !trip.StopTimes[i].StopID.Valid { + continue + } s2 := trip.StopTimes[i].StopID.Val key := s1 + ":" + s2 // todo: use a real separator... dx, ok := e.stopDist[key] diff --git a/rules/flex_stop_location_type.go b/rules/flex_stop_location_type.go index 79d4f81ba..fad822d9d 100644 --- a/rules/flex_stop_location_type.go +++ b/rules/flex_stop_location_type.go @@ -55,7 +55,7 @@ func (e *FlexStopLocationTypeCheck) AfterWrite(eid string, ent tt.Entity, emap * st.PickupBookingRuleID.Valid || st.DropOffBookingRuleID.Valid - if isFlex { + if isFlex && st.StopID.Valid { e.flexStops[st.StopID.Val] = true } } diff --git a/rules/too_far_from_shape.go b/rules/too_far_from_shape.go index 1ae034f16..f8aba3598 100644 --- a/rules/too_far_from_shape.go +++ b/rules/too_far_from_shape.go @@ -56,6 +56,9 @@ func (e *StopTooFarFromShapeCheck) Validate(ent tt.Entity) []error { } var errs []error for _, st := range v.StopTimes { + if !st.StopID.Valid { + continue + } // Check the cache if e.checked[shapeid][st.StopID.Val] { continue