diff options
41 files changed, 1294 insertions, 67 deletions
| diff --git a/app/assets/stylesheets/components/_tables.sass b/app/assets/stylesheets/components/_tables.sass index 1e02ad586..ef19bd538 100644 --- a/app/assets/stylesheets/components/_tables.sass +++ b/app/assets/stylesheets/components/_tables.sass @@ -374,6 +374,8 @@      .th        text-align: right        border-top-color: transparent +      > div:not(.btn-group) +        min-height: 20px      .td > .headlined        &:before @@ -408,7 +410,7 @@        .th          > div:not(.btn-group) -          min-height: 19px +          min-height: 20px          > *:first-child            padding-right: 30px diff --git a/app/assets/stylesheets/modules/_vj_collection.sass b/app/assets/stylesheets/modules/_vj_collection.sass index d99c67bd7..d9079daa2 100644 --- a/app/assets/stylesheets/modules/_vj_collection.sass +++ b/app/assets/stylesheets/modules/_vj_collection.sass @@ -86,6 +86,20 @@          &:after            bottom: 50% + +  .table-2entries .t2e-head +    .detailed-timetables +      .fa +        margin-right: 5px +    .detailed-timetables-bt +      text-decoration: none +      .fa +        margin-right: 5px +        color: $red +        transition: transform 0.1s +      &.active .fa +        transform: rotate(180deg) +    .table-2entries .t2e-head > .td:nth-child(2) > div,    .table-2entries .t2e-head > .td:last-child > div,    .table-2entries.no_result .t2e-head > .td:last-child > div @@ -103,6 +117,54 @@        top: 50%        margin-top: -8px +  .detailed-timetables +    padding-top: 10px +    text-align: left +    margin-bottom: -5px + +    & > div +      position: relative +      border-left: 1px solid $lightgrey +      padding-left: 10px +      a +        text-decoration: none +        border: none +      &:before +        position: absolute +        left: 0px +        top: 0 +        right: -8px +        content: "" +        border-top: 1px solid $lightgrey +      font-size: 0.8em +      height: 44px +      position: relative +      padding-bottom: 5px + +      p +        margin: 0 +      p:first-child +        padding-top: 8px +        font-weight: bold + +  .t2e-item-list .detailed-timetables > div +    border-left: none +    &:after +      top: 50% +      left: 50% +      content: "" +      border: 1px solid black +      width: 20px +      height: 20px +      margin-left: -10px +      margin-top: -10px +      position: absolute +      border-radius: 20px +    &.active:after +      background: black +    &:before +      left: -8px +    // Errors    .table-2entries .t2e-item-list      .t2e-item diff --git a/app/controllers/referential_vehicle_journeys_controller.rb b/app/controllers/referential_vehicle_journeys_controller.rb index 2ce28a5cc..e36ef8153 100644 --- a/app/controllers/referential_vehicle_journeys_controller.rb +++ b/app/controllers/referential_vehicle_journeys_controller.rb @@ -20,11 +20,30 @@ class ReferentialVehicleJourneysController < ChouetteController      @q = ransack_period_range(scope: @q, error_message:  t('vehicle_journeys.errors.purchase_window'), query: :in_purchase_window, prefix: :purchase_window)      @q = ransack_period_range(scope: @q, error_message:  t('vehicle_journeys.errors.time_table'), query: :with_matching_timetable, prefix: :time_table)      @q = @q.ransack(params[:q]) -    @vehicle_journeys ||= @q.result.order(:published_journey_name).includes(:vehicle_journey_at_stops).paginate page: params[:page], per_page: params[:per_page] || 10 +    @vehicle_journeys ||= @q.result +    @vehicle_journeys = parse_order @vehicle_journeys +    @vehicle_journeys = @vehicle_journeys.paginate page: params[:page], per_page: params[:per_page] || 10      @all_companies = Chouette::Company.where("id IN (#{@referential.vehicle_journeys.select(:company_id).to_sql})").distinct      @all_stop_areas = Chouette::StopArea.where("id IN (#{@referential.vehicle_journeys.joins(:stop_areas).select("stop_areas.id").to_sql})").distinct      stop_area_ids = params[:q].try(:[], :stop_area_ids).try(:select, &:present?)      @filters_stop_areas = Chouette::StopArea.find(stop_area_ids) if stop_area_ids.present? && stop_area_ids.size <= 2    end +  def parse_order scope +    return scope.order(:published_journey_name) unless params[:sort].present? +    direction = params[:direction] || "asc" +    attributes = Chouette::VehicleJourney.column_names.map{|n| "vehicle_journeys.#{n}"}.join(',') +    case params[:sort] +      when "line" +        scope.order("lines.name #{direction}").joins(route: :line) +      when "route" +        scope.order("routes.name #{direction}").joins(:route) +      when "departure_time" +        scope.joins(:vehicle_journey_at_stops).group(attributes).select(attributes).order("MIN(vehicle_journey_at_stops.departure_time) #{direction}") +      when "arrival_time" +        scope.joins(:vehicle_journey_at_stops).group(attributes).select(attributes).order("MAX(vehicle_journey_at_stops.departure_time) #{direction}") +      else +        scope.order "#{params[:sort]} #{direction}" +      end +  end  end diff --git a/app/controllers/vehicle_journeys_controller.rb b/app/controllers/vehicle_journeys_controller.rb index a389f3ab0..14795227c 100644 --- a/app/controllers/vehicle_journeys_controller.rb +++ b/app/controllers/vehicle_journeys_controller.rb @@ -154,7 +154,7 @@ class VehicleJourneysController < ChouetteController    private    def load_custom_fields -    @custom_fields = current_workgroup&.custom_fields_definitions || {} +    @custom_fields = referential.workgroup&.custom_fields_definitions || {}    end    def map_stop_points points @@ -185,7 +185,9 @@ class VehicleJourneysController < ChouetteController          :long_lat_type => sp.stop_area.try(:long_lat_type),          :country_code => sp.stop_area.try(:country_code),          :country_name => sp.stop_area.try(:country_name), -        :street_name => sp.stop_area.try(:street_name) +        :street_name => sp.stop_area.try(:street_name), +        :waiting_time => sp.stop_area.try(:waiting_time), +        :waiting_time_text => sp.stop_area.decorate.try(:waiting_time_text),        }      end    end diff --git a/app/javascript/helpers/stop_area_header_manager.js b/app/javascript/helpers/stop_area_header_manager.js index 5b18e2f63..1003b2cf6 100644 --- a/app/javascript/helpers/stop_area_header_manager.js +++ b/app/javascript/helpers/stop_area_header_manager.js @@ -15,11 +15,15 @@ export default class StopAreaHeaderManager {      let index = this.ids_list.indexOf(object_id)      let sp = this.stopPointsList[index]      let showHeadline = this.showHeader(object_id) +    let title = sp.city_name ? sp.city_name + ' (' + sp.zip_code +')' : "" +    if(sp.waiting_time > 0){ +      title += " | " + sp.waiting_time_text +    }      return (        <div          className={(showHeadline) ? 'headlined' : ''}          data-headline={showHeadline} -        title={sp.city_name ? sp.city_name + ' (' + sp.zip_code +')' : ""} +        title={title}        >          <span>            <span> diff --git a/app/javascript/vehicle_journeys/actions/index.js b/app/javascript/vehicle_journeys/actions/index.js index 4ca8bd73b..b398d78fa 100644 --- a/app/javascript/vehicle_journeys/actions/index.js +++ b/app/javascript/vehicle_journeys/actions/index.js @@ -348,21 +348,11 @@ const actions = {              var purchaseWindows = []              let tt              for (tt of val.time_tables){ -              timeTables.push({ -                objectid: tt.objectid, -                comment: tt.comment, -                id: tt.id, -                color: tt.color -              }) +              timeTables.push(tt)              }              if(val.purchase_windows){                for (tt of val.purchase_windows){ -                purchaseWindows.push({ -                  objectid: tt.objectid, -                  name: tt.name, -                  id: tt.id, -                  color: tt.color -                }) +                purchaseWindows.push(tt)                }              }              let vjasWithDelta = val.vehicle_journey_at_stops.map((vjas, i) => { @@ -527,6 +517,22 @@ const actions = {          minute: actions.simplePad(newArrivalDT.getUTCMinutes())        }      } +  }, +  addMinutesToTime: (time, minutes) => { +    let res = { +      hour: time.hour, +      minute: time.minute +    } +    let delta_hour = parseInt(minutes/60) +    let delta_minute = minutes - 60*delta_hour +    res.hour += delta_hour +    res.minute += delta_minute +    let extra_hours = parseInt(res.minute/60) +    res.hour += extra_hours +    res.minute -= extra_hours*60 +    res.hour = res.hour % 24 + +    return res    }  } diff --git a/app/javascript/vehicle_journeys/components/VehicleJourney.js b/app/javascript/vehicle_journeys/components/VehicleJourney.js index 4a9432231..e11e91497 100644 --- a/app/javascript/vehicle_journeys/components/VehicleJourney.js +++ b/app/javascript/vehicle_journeys/components/VehicleJourney.js @@ -48,12 +48,24 @@ export default class VehicleJourney extends Component {      }    } +  hasTimeTable(time_tables, tt) { +    let found = false +    time_tables.map((t, index) => { +      if(t.id == tt.id){ +        found = true +        return +      } +    }) +    return found +  } +    isDisabled(bool1, bool2) {      return (bool1 || bool2)    }    render() {      this.previousCity = undefined +    let detailed_calendars = this.hasFeature('detailed_calendars') && !this.disabled      let {time_tables, purchase_windows} = this.props.value      return ( @@ -68,20 +80,20 @@ export default class VehicleJourney extends Component {            <div>{this.props.value.published_journey_name && this.props.value.published_journey_name != I18n.t('undefined') ? this.props.value.published_journey_name : '-'}</div>            <div>{this.props.value.journey_pattern.short_id || '-'}</div>            <div>{this.props.value.company ? this.props.value.company.name : '-'}</div> +          { this.hasFeature('purchase_windows') && +            <div> +            {purchase_windows.slice(0,3).map((tt, i)=> +              <span key={i} className='vj_tt'>{this.purchaseWindowURL(tt)}</span> +            )} +            {purchase_windows.length > 3 && <span className='vj_tt'> + {purchase_windows.length - 3}</span>} +            </div> +          }            <div>              {time_tables.slice(0,3).map((tt, i)=>                <span key={i} className='vj_tt'>{this.timeTableURL(tt)}</span>              )}              {time_tables.length > 3 && <span className='vj_tt'> + {time_tables.length - 3}</span>}            </div> -          { this.hasFeature('purchase_windows') && -            <div> -              {purchase_windows.slice(0,3).map((tt, i)=> -                <span key={i} className='vj_tt'>{this.purchaseWindowURL(tt)}</span> -              )} -              {purchase_windows.length > 3 && <span className='vj_tt'> + {purchase_windows.length - 3}</span>} -            </div> -          }            {!this.props.disabled && <div className={(this.props.value.deletable ? 'disabled ' : '') + 'checkbox'}>              <input                id={this.props.index} @@ -94,7 +106,16 @@ export default class VehicleJourney extends Component {              ></input>              <label htmlFor={this.props.index}></label>            </div>} +            {this.props.disabled && <VehicleJourneyInfoButton vehicleJourney={this.props.value} />} + +          { detailed_calendars && +            <div className="detailed-timetables hidden"> +            {this.props.allTimeTables.map((tt, i) => +              <div key={i} className={(this.hasTimeTable(time_tables, tt) ? "active" : "inactive")}></div> +            )} +            </div> +          }          </div>          {this.props.value.vehicle_journey_at_stops.map((vj, i) =>            <div key={i} className='td text-center'> @@ -174,4 +195,5 @@ VehicleJourney.propTypes = {    onUpdateTime: PropTypes.func.isRequired,    onSelectVehicleJourney: PropTypes.func.isRequired,    vehicleJourneys: PropTypes.object.isRequired, +  allTimeTables: PropTypes.array.isRequired,  } diff --git a/app/javascript/vehicle_journeys/components/VehicleJourneys.js b/app/javascript/vehicle_journeys/components/VehicleJourneys.js index 01e07ee0c..843aec1a8 100644 --- a/app/javascript/vehicle_journeys/components/VehicleJourneys.js +++ b/app/javascript/vehicle_journeys/components/VehicleJourneys.js @@ -12,6 +12,7 @@ export default class VehicleJourneys extends Component {        this.stopPoints(),        this.props.filters.features      ) +    this.toggleTimetables = this.toggleTimetables.bind(this)    }    isReturn() { @@ -48,9 +49,35 @@ export default class VehicleJourneys extends Component {      return this.headerManager.showHeader(object_id)    } +  allTimeTables() { +    if(this._allTimeTables){ +      return this._allTimeTables +    } +    let keys = [] +    this._allTimeTables = [] +    this.vehicleJourneysList().map((vj, index) => { +      vj.time_tables.map((tt, _) => { +        if(keys.indexOf(tt.id) < 0){ +            keys.push(tt.id) +            this._allTimeTables.push(tt) +        } +      }) +    }) +    return this._allTimeTables +  } + +  toggleTimetables(e) { +    $('.table-2entries .detailed-timetables').toggleClass('hidden') +    $('.table-2entries .detailed-timetables-bt').toggleClass('active') +    this.componentDidUpdate() +    e.preventDefault() +    false +  } +    componentDidUpdate(prevProps, prevState) {      if(this.props.status.isFetching == false){        $('.table-2entries').each(function() { +        $(this).find('.th').css('height', 'auto')          var refH = []          var refCol = [] @@ -91,9 +118,19 @@ export default class VehicleJourneys extends Component {      }    } +  timeTableURL(tt) { +    let refURL = window.location.pathname.split('/', 3).join('/') +    let ttURL = refURL + '/time_tables/' + tt.id + +    return ( +      <a href={ttURL} title='Voir le calendrier'><span className='fa fa-calendar' style={{color: (tt.color ? tt.color : '#4B4B4B')}}></span>{tt.days || tt.comment}</a> +    ) +  } +    render() {      this.previousBreakpoint = undefined - +    this._allTimeTables = null +    let detailed_calendars = this.hasFeature('detailed_calendars') && !this.isReturn() && (this.allTimeTables().length > 0)      if(this.props.status.isFetching == true) {        return (          <div className="isLoading" style={{marginTop: 80, marginBottom: 80}}> @@ -133,8 +170,28 @@ export default class VehicleJourneys extends Component {                    <div>{I18n.attribute_name("vehicle_journey", "name")}</div>                    <div>{I18n.attribute_name("vehicle_journey", "journey_pattern_id")}</div>                    <div>{I18n.model_name("company")}</div> -                  <div>{I18n.model_name("time_table", "plural": true)}</div>                    { this.hasFeature('purchase_windows') && <div>{I18n.model_name("purchase_window", "plural": true)}</div> } +                  <div> +                    { detailed_calendars && +                      <a href='#' onClick={this.toggleTimetables} className='detailed-timetables-bt'> +                        <span className='fa fa-angle-up'></span> +                        {I18n.model_name("time_table", "plural": true)} +                      </a> +                    } +                    { !detailed_calendars && I18n.model_name("time_table", "plural": true)} +                  </div> +                  { !this.isReturn() && +                    <div className="detailed-timetables hidden"> +                      {this.allTimeTables().map((tt, i)=> +                        <div key={i}> +                          <p> +                            {this.timeTableURL(tt)} +                          </p> +                          <p>{tt.bounding_dates}</p> +                        </div> +                      )} +                    </div> +                  }                  </div>                  {this.stopPoints().map((sp, i) =>{                    return ( @@ -159,6 +216,7 @@ export default class VehicleJourneys extends Component {                        onSelectVehicleJourney={this.props.onSelectVehicleJourney}                        vehicleJourneys={this}                        disabled={this.isReturn()} +                      allTimeTables={this.allTimeTables()}                        />                    )}                  </div> diff --git a/app/javascript/vehicle_journeys/reducers/vehicleJourneys.js b/app/javascript/vehicle_journeys/reducers/vehicleJourneys.js index 1a15ec46d..383dea4a0 100644 --- a/app/javascript/vehicle_journeys/reducers/vehicleJourneys.js +++ b/app/javascript/vehicle_journeys/reducers/vehicleJourneys.js @@ -19,26 +19,17 @@ const vehicleJourney= (state = {}, action, keep) => {          current_time.minute = parseInt(action.data["start_time.minute"].value) || 0        }        _.each(action.stopPointsList, (sp) =>{ +        let inJourney = false          if(action.selectedJourneyPattern.full_schedule && action.selectedJourneyPattern.costs && action.selectedJourneyPattern.costs[prevSp.stop_area_id + "-" + sp.stop_area_id]){            let delta = parseInt(action.selectedJourneyPattern.costs[prevSp.stop_area_id + "-" + sp.stop_area_id].time) -          let delta_hour = parseInt(delta/60) -          let delta_minute = delta - 60*delta_hour -          current_time.hour += delta_hour -          current_time.minute += delta_minute -          let extra_hours = parseInt(current_time.minute/60) -          current_time.hour += extra_hours -          current_time.minute -= extra_hours*60 -          current_time.hour = current_time.hour % 24 +          current_time = actions.addMinutesToTime(current_time, delta)            prevSp = sp +          inJourney = true          }          let offsetHours = sp.time_zone_offset / 3600          let offsetminutes = sp.time_zone_offset/60 - 60*offsetHours          let newVjas = {            delta: 0, -          departure_time:{ -            hour: (24 + current_time.hour + offsetHours) % 24, -            minute: current_time.minute + offsetminutes -          },            arrival_time:{              hour: (24 + current_time.hour + offsetHours) % 24,              minute: current_time.minute + offsetminutes @@ -47,6 +38,16 @@ const vehicleJourney= (state = {}, action, keep) => {            stop_area_cityname: sp.city_name,            dummy: true          } + +        if(sp.waiting_time && inJourney){ +          current_time = actions.addMinutesToTime(current_time, parseInt(sp.waiting_time)) +        } + +        newVjas.departure_time = { +          hour: (24 + current_time.hour + offsetHours) % 24, +          minute: current_time.minute + offsetminutes +        } +          if(current_time.hour + offsetHours > 24){            newVjas.departure_day_offset = 1            newVjas.arrival_day_offset = 1 diff --git a/app/models/chouette/company.rb b/app/models/chouette/company.rb index b3d40ab96..53e412600 100644 --- a/app/models/chouette/company.rb +++ b/app/models/chouette/company.rb @@ -15,6 +15,5 @@ module Chouette        [:organizational_unit, :operating_department_name, :code, :phone, :fax, :email, :url, :time_zone]      end -    end  end diff --git a/app/models/chouette/journey_pattern.rb b/app/models/chouette/journey_pattern.rb index aa9fdb810..830e985d9 100644 --- a/app/models/chouette/journey_pattern.rb +++ b/app/models/chouette/journey_pattern.rb @@ -170,5 +170,21 @@ module Chouette        end        full      end + +    def set_distances distances +      raise "inconsistent data: #{distances.count} values for #{stop_points.count} stops" unless distances.count == stop_points.count +      prev = distances[0].to_i +      _costs = self.costs +      distances[1..-1].each_with_index do |distance, i| +        distance = distance.to_i +        relative = distance - prev +        prev = distance +        start, stop = stop_points[i..i+1] +        key = "#{start.stop_area_id}-#{stop.stop_area_id}" +        _costs[key] ||= {} +        _costs[key]["distance"] = relative +      end +      self.costs = _costs +    end    end  end diff --git a/app/models/chouette/line.rb b/app/models/chouette/line.rb index ba2e2755d..d077d5c9d 100644 --- a/app/models/chouette/line.rb +++ b/app/models/chouette/line.rb @@ -41,6 +41,7 @@ module Chouette      validates_presence_of :name +      scope :by_text, ->(text) { where('lower(name) LIKE :t or lower(published_name) LIKE :t or lower(objectid) LIKE :t or lower(comment) LIKE :t or lower(number) LIKE :t',        t: "%#{text.downcase}%") } @@ -80,6 +81,14 @@ module Chouette        line_referential.companies.where(id: ([company_id] + Array(secondary_company_ids)).compact)      end +    def deactivate +      self.deactivated = true +    end + +    def activate +      self.deactivated = false +    end +      def deactivate!        update_attribute :deactivated, true      end diff --git a/app/models/chouette/purchase_window.rb b/app/models/chouette/purchase_window.rb index 334493015..157390a21 100644 --- a/app/models/chouette/purchase_window.rb +++ b/app/models/chouette/purchase_window.rb @@ -19,6 +19,7 @@ module Chouette      scope :contains_date, ->(date) { where('date ? <@ any (date_ranges)', date) }      scope :overlap_dates, ->(date_range) { where('daterange(?, ?) && any (date_ranges)', date_range.first, date_range.last + 1.day) } +    scope :matching_dates, ->(date_range) { where('ARRAY[daterange(?, ?)] = date_ranges', date_range.first, date_range.last + 1.day) }      def self.ransackable_scopes(auth_object = nil)        [:contains_date] diff --git a/app/models/chouette/route.rb b/app/models/chouette/route.rb index 5cc5d8b0d..3729deb7d 100644 --- a/app/models/chouette/route.rb +++ b/app/models/chouette/route.rb @@ -185,6 +185,12 @@ module Chouette        return true      end +    def full_journey_pattern +      journey_pattern = journey_patterns.find_or_create_by registration_number: self.number, name: self.name +      journey_pattern.stop_points = self.stop_points +      journey_pattern +    end +      protected      def self.vehicle_journeys_timeless(stop_point_id) diff --git a/app/models/chouette/stop_area.rb b/app/models/chouette/stop_area.rb index 830fe8b78..7170dd217 100644 --- a/app/models/chouette/stop_area.rb +++ b/app/models/chouette/stop_area.rb @@ -369,6 +369,14 @@ module Chouette        !activated?      end +    def activate +      self.deleted_at = nil +    end + +    def deactivate +      self.deleted_at = Time.now +    end +      def activate!        update_attribute :deleted_at, nil      end diff --git a/app/models/chouette/time_table.rb b/app/models/chouette/time_table.rb index 15b22b671..b76de852a 100644 --- a/app/models/chouette/time_table.rb +++ b/app/models/chouette/time_table.rb @@ -44,10 +44,10 @@ module Chouette          attrs << self.int_day_types          dates = self.dates          dates += TimeTableDate.where(time_table_id: self.id) -        attrs << dates.map(&:checksum).map(&:to_s).sort +        attrs << dates.map(&:checksum).map(&:to_s).uniq.sort          periods = self.periods          periods += TimeTablePeriod.where(time_table_id: self.id) -        attrs << periods.map(&:checksum).map(&:to_s).sort +        attrs << periods.map(&:checksum).map(&:to_s).uniq.sort        end      end diff --git a/app/models/chouette/vehicle_journey.rb b/app/models/chouette/vehicle_journey.rb index 4a6ba3f75..1a79db823 100644 --- a/app/models/chouette/vehicle_journey.rb +++ b/app/models/chouette/vehicle_journey.rb @@ -105,7 +105,7 @@ module Chouette          attrs << self.try(:company).try(:get_objectid).try(:local_id)          attrs << self.footnotes.map(&:checksum).sort          vjas =  self.vehicle_journey_at_stops -        vjas += VehicleJourneyAtStop.where(vehicle_journey_id: self.id) +        vjas += VehicleJourneyAtStop.where(vehicle_journey_id: self.id) unless self.new_record?          attrs << vjas.uniq.sort_by { |s| s.stop_point&.position }.map(&:checksum).sort        end      end @@ -381,8 +381,8 @@ module Chouette      end      def self.lines -      lines_query = joins(:route).select("routes.line_id").to_sql -      Chouette::Line.where("id IN (#{lines_query})") +      lines_query = joins(:route).select("routes.line_id").reorder(nil).except(:group).pluck(:'routes.line_id') +      Chouette::Line.where(id: lines_query)      end    end  end diff --git a/app/models/chouette/vehicle_journey_at_stop.rb b/app/models/chouette/vehicle_journey_at_stop.rb index eda711ade..3b4f35f13 100644 --- a/app/models/chouette/vehicle_journey_at_stop.rb +++ b/app/models/chouette/vehicle_journey_at_stop.rb @@ -41,7 +41,7 @@ module Chouette            :arrival_day_offset,            I18n.t(              'vehicle_journey_at_stops.errors.day_offset_must_not_exceed_max', -            short_id: vehicle_journey.get_objectid.short_id, +            short_id: vehicle_journey&.get_objectid&.short_id,              max: DAY_OFFSET_MAX + 1            )          ) @@ -52,7 +52,7 @@ module Chouette            :departure_day_offset,            I18n.t(              'vehicle_journey_at_stops.errors.day_offset_must_not_exceed_max', -            short_id: vehicle_journey.get_objectid.short_id, +            short_id: vehicle_journey&.get_objectid&.short_id,              max: DAY_OFFSET_MAX + 1            )          ) @@ -69,8 +69,8 @@ module Chouette      def checksum_attributes        [].tap do |attrs| -        attrs << self.departure_time.try(:to_s, :time) -        attrs << self.arrival_time.try(:to_s, :time) +        attrs << self.departure_time&.utc.try(:to_s, :time) +        attrs << self.arrival_time&.utc.try(:to_s, :time)          attrs << self.departure_day_offset.to_s          attrs << self.arrival_day_offset.to_s        end diff --git a/app/models/chouette/vehicle_journey_at_stops_day_offset.rb b/app/models/chouette/vehicle_journey_at_stops_day_offset.rb index b2cb90d11..7497cd72c 100644 --- a/app/models/chouette/vehicle_journey_at_stops_day_offset.rb +++ b/app/models/chouette/vehicle_journey_at_stops_day_offset.rb @@ -11,13 +11,19 @@ module Chouette        @at_stops.inject(nil) do |prior_stop, stop|          next stop if prior_stop.nil? -        if stop.arrival_time < prior_stop.departure_time || -            stop.arrival_time < prior_stop.arrival_time +        # we only compare time of the day, not actual times +        stop_arrival_time = stop.arrival_time - stop.arrival_time.to_date.to_time +        stop_departure_time = stop.departure_time - stop.departure_time.to_date.to_time +        prior_stop_arrival_time = prior_stop.arrival_time - prior_stop.arrival_time.to_date.to_time +        prior_stop_departure_time = prior_stop.departure_time - prior_stop.departure_time.to_date.to_time + +        if stop_arrival_time < prior_stop_departure_time || +            stop_arrival_time < prior_stop_arrival_time            arrival_offset += 1          end -        if stop.departure_time < stop.arrival_time || -            stop.departure_time < prior_stop.departure_time +        if stop_departure_time < stop_arrival_time || +            stop_departure_time < prior_stop_departure_time            departure_offset += 1          end @@ -39,4 +45,4 @@ module Chouette        save      end    end -end
\ No newline at end of file +end diff --git a/app/models/referential.rb b/app/models/referential.rb index 509e0412f..09c2e7d34 100644 --- a/app/models/referential.rb +++ b/app/models/referential.rb @@ -51,7 +51,9 @@ class Referential < ActiveRecord::Base    belongs_to :stop_area_referential    validates_presence_of :stop_area_referential    has_many :stop_areas, through: :stop_area_referential +    belongs_to :workbench +  delegate :workgroup, to: :workbench, allow_nil: true    belongs_to :referential_suite diff --git a/app/models/simple_importer.rb b/app/models/simple_importer.rb new file mode 100644 index 000000000..b824d596d --- /dev/null +++ b/app/models/simple_importer.rb @@ -0,0 +1,422 @@ +class SimpleImporter < ActiveRecord::Base +  attr_accessor :configuration + +  def self.define name +    @importers ||= {} +    configuration = Configuration.new name +    yield configuration +    configuration.validate! +    @importers[name.to_sym] = configuration +  end + +  def self.find_configuration name +    @importers ||= {} +    configuration = @importers[name.to_sym] +    raise "Importer not found: #{name}" unless configuration +    configuration +  end + +  def initialize *args +    super *args +    self.configuration = self.class.find_configuration self.configuration_name +    self.journal ||= [] +  end + +  def configure +    new_config = configuration.duplicate +    yield new_config +    new_config.validate! +    self.configuration = new_config +  end + +  def context +    self.configuration.context +  end + +  def resolve col_name, value, &block +    val = block.call(value) +    return val if val.present? +    @resolution_queue[[col_name.to_s, value]].push({record: @current_record, attribute: @current_attribute, block: block}) +    nil +  end + +  def import opts={} +    @verbose = opts.delete :verbose + + +    @resolution_queue = Hash.new{|h,k| h[k] = []} +    @errors = [] +    @messages = [] +    @number_of_lines = 0 +    @padding = 1 +    @current_line = 0 +    fail_with_error "File not found: #{self.filepath}" do +      @number_of_lines = CSV.read(self.filepath, self.configuration.csv_options).length +      @padding = [1, Math.log(@number_of_lines, 10).ceil()].max +    end + + +    self.configuration.before_actions(:parsing).each do |action| action.call self end + +    @statuses = "" + +    if ENV["NO_TRANSACTION"] +      process_csv_file +    else +      ActiveRecord::Base.transaction do +        process_csv_file +      end +    end +    self.status ||= :success +  rescue FailedImport +    self.status = :failed +  ensure +    self.save! +  end + +  def fail_with_error msg=nil, opts={} +    begin +      yield +    rescue => e +      msg = msg.call if msg.is_a?(Proc) +      custom_print "\nFAILED: \n errors: #{msg}\n exception: #{e.message}\n#{e.backtrace.join("\n")}", color: :red unless self.configuration.ignore_failures +      push_in_journal({message: msg, error: e.message, event: :error, kind: :error}) +      @new_status = colorize("x", :red) +      if self.configuration.ignore_failures +        raise FailedRow if opts[:abort_row] +      else +        raise FailedImport +      end +    end +  end + +  def encode_string s +    s.encode("utf-8").force_encoding("utf-8") +  end + +  def dump_csv_from_context +    filepath = "./#{self.configuration_name}_#{Time.now.strftime "%y%m%d%H%M"}.csv" +    # for some reason, context[:csv].to_csv does not work +    CSV.open(filepath, 'w') do |csv| +      header = true +      context[:csv].each do |row| +        csv << row.headers if header +        csv << row.fields +        header = false +      end +    end +    log "CSV file dumped in #{filepath}" +  end + +  def log msg, opts={} +    msg = colorize msg, opts[:color] if opts[:color] +    if opts[:append] +      @messages[-1] = (@messages[-1] || "") + msg +    else +      @messages << msg +    end +    print_state +  end + +  protected + +  def process_csv_file +    self.configuration.before_actions(:all).each do |action| action.call self end +    log "Starting import ...", color: :green + +    (context[:csv] || CSV.read(filepath, self.configuration.csv_options)).each do |row| +      @current_row = row +      @new_status = nil +      begin +        handle_row row +        fail_with_error ->(){ @current_record.errors.messages } do +          new_record = @current_record&.new_record? +          @new_status ||= new_record ? colorize("✓", :green) : colorize("-", :orange) +          @event = new_record ? :creation : :update +          self.configuration.before_actions(:each_save).each do |action| +            action.call self, @current_record +          end +          ### This could fail if the record has a mandatory relation which is not yet resolved +          ### TODO: do not attempt to save if the current record if waiting for resolution +          ###       and fail at the end if there remains unresolved relations +          if @current_record +            if self.configuration.ignore_failures +              unless @current_record.save +                @new_status = colorize("x", :red) +                push_in_journal({message: "errors: #{@current_record.errors.messages}", error: "invalid record", event: :error, kind: :error}) +              end +            else +              @current_record.save! +            end +          end +          self.configuration.after_actions(:each_save).each do |action| +            action.call self, @current_record +          end +        end +      rescue FailedRow +        @new_status = colorize("x", :red) +      end +      push_in_journal({event: @event, kind: :log}) if @current_record&.valid? +      @statuses += @new_status +      self.configuration.columns.each do |col| +      if @current_record && col.name && @resolution_queue.any? +        val = @current_record.send col[:attribute] +        (@resolution_queue.delete([col.name, val]) || []).each do |res| +          record = res[:record] +          attribute = res[:attribute] +          value = res[:block].call(val, record) +          record.send "#{attribute}=", value +          record.save! +        end +      end +    end +      print_state +      @current_line += 1 +    end + +    begin +      self.configuration.after_actions(:all).each do |action| +        action.call self +      end +    rescue FailedRow +    end +  end + +  def handle_row row +    if self.configuration.get_custom_handler +      instance_exec(row, &self.configuration.get_custom_handler) +    else +      fail_with_error "", abort_row: true do +        @current_record = self.configuration.find_record row +        self.configuration.columns.each do |col| +          @current_attribute = col[:attribute] +          val = col[:value] +          if val.nil? || val.is_a?(Proc) +            if row.has_key? col.name +              if val.is_a?(Proc) +                val = instance_exec(row[col.name], &val) +              else +                val = row[col.name] +              end +            else +              push_in_journal({event: :column_not_found, message: "Column not found: #{col.name}", kind: :warning}) +              self.status ||= :success_with_warnings +            end +          end + +          if val.nil? && col.required? +            raise "MISSING VALUE FOR COLUMN #{col.name}" +          end +          val = encode_string(val) if val.is_a?(String) +          @current_record.send "#{@current_attribute}=", val if val +        end +      end +    end +  end + +  def push_in_journal data +    line = @current_line + 1 +    line += 1 if configuration.headers +    self.journal.push data.update(line: line, row: @current_row) +    if data[:kind] == :error || data[:kind] == :warning +      @errors.push data +    end +  end + +  def colorize txt, color +    color = { +      red: "31", +      green: "32", +      orange: "33", +    }[color] || "33" +    "\e[#{color}m#{txt}\e[0m" +  end + +  def print_state +    return unless @verbose + +    @status_width ||= begin +      term_width = %x(tput cols).to_i +      term_width - @padding - 10 +    rescue +      100 +    end + +    @status_height ||= begin +      term_height = %x(tput lines).to_i +      term_height - 3 +    rescue +      50 +    end + +    full_status = @statuses || "" +    full_status = full_status.last(@status_width*10) || "" +    padding_size = [(@number_of_lines - @current_line - 1), (@status_width - full_status.size/10)].min +    full_status = "#{full_status}#{"."*[padding_size, 0].max}" + +    msg = "#{"%#{@padding}d" % (@current_line + 1)}/#{@number_of_lines}: #{full_status}" + +    lines_count = [(@status_height / 2) - 3, 1].max + +    if @messages.any? +      msg += "\n\n" +      msg += colorize "=== MESSAGES (#{@messages.count}) ===\n", :green +      msg += "[...]\n" if @messages.count > lines_count +      msg += @messages.last(lines_count).join("\n") +      msg += "\n"*[lines_count-@messages.count, 0].max +    end + +    if @errors.any? +      msg += "\n\n" +      msg += colorize "=== ERRORS (#{@errors.count}) ===\n", :red +      msg += "[...]\n" if @errors.count > lines_count +      msg += @errors.last(lines_count).map do |j| +        kind = j[:kind] +        kind = colorize(kind, kind == :error ? :red : :orange) +        encode_string "[#{kind}]\t\tL#{j[:line]}\t#{j[:error]}\t\t#{j[:message]}" +      end.join("\n") +    end +    custom_print msg, clear: true +  end + +  def custom_print msg, opts={} +    return unless @verbose +    out = "" +    msg = colorize(msg, opts[:color]) if opts[:color] +    puts "\e[H\e[2J" if opts[:clear] +    out += msg +    print out +  end + +  class FailedImport < RuntimeError +  end + +  class FailedRow < RuntimeError +  end + +  class Configuration +    attr_accessor :model, :headers, :separator, :key, :context, :encoding, :ignore_failures, :scope +    attr_reader :columns + +    def initialize import_name, opts={} +      @import_name = import_name +      @key = opts[:key] || "id" +      @headers = opts.has_key?(:headers) ? opts[:headers] : true +      @separator = opts[:separator] || "," +      @encoding = opts[:encoding] +      @columns = opts[:columns] || [] +      @model = opts[:model] +      @custom_handler = opts[:custom_handler] +      @before = opts[:before] +      @after = opts[:after] +      @ignore_failures = opts[:ignore_failures] +      @context = opts[:context] || {} +      @scope = opts[:scope] +    end + +    def duplicate +      Configuration.new @import_name, self.options +    end + +    def options +      { +        key: @key, +        headers: @headers, +        separator: @separator, +        encoding: @encoding, +        columns: @columns.map(&:duplicate), +        model: model, +        custom_handler: @custom_handler, +        before: @before, +        after: @after, +        ignore_failures: @ignore_failures, +        context: @context, +        scope: @scope +      } +    end + +    def validate! +      raise "Incomplete configuration, missing model for #{@import_name}" unless model.present? +    end + +    def attribute_for_col col_name +      column = self.columns.find{|c| c.name == col_name} +      column && column[:attribute] || col_name +    end + +    def record_scope +      _scope = @scope +      _scope = instance_exec(&_scope) if _scope.is_a?(Proc) +      _scope || model +    end + +    def find_record attrs +      record_scope.find_or_initialize_by(attribute_for_col(@key) => attrs[@key.to_s]) +    end + +    def csv_options +      { +        headers: self.headers, +        col_sep: self.separator, +        encoding: self.encoding +      } +    end + +    def add_column name, opts={} +      @columns.push Column.new({name: name.to_s}.update(opts)) +    end + +    def add_value attribute, value +      @columns.push Column.new({attribute: attribute, value: value}) +    end + +    def before group=:all, &block +      @before ||= Hash.new{|h, k| h[k] = []} +      @before[group].push block +    end + +    def after group=:all, &block +      @after ||= Hash.new{|h, k| h[k] = []} +      @after[group].push block +    end + +    def before_actions group=:all +      @before ||= Hash.new{|h, k| h[k] = []} +      @before[group] +    end + +    def after_actions group=:all +      @after ||= Hash.new{|h, k| h[k] = []} +      @after[group] +    end + +    def custom_handler &block +      @custom_handler = block +    end + +    def get_custom_handler +      @custom_handler +    end + +    class Column +      attr_accessor :name +      def initialize opts={} +        @name = opts[:name] +        @options = opts +        @options[:attribute] ||= @name +      end + +      def duplicate +        Column.new @options.dup +      end + +      def required? +        !!@options[:required] +      end + +      def [](key) +        @options[key] +      end +    end +  end +end diff --git a/app/views/referential_vehicle_journeys/index.html.slim b/app/views/referential_vehicle_journeys/index.html.slim index 69e29597c..ca1b1ecd9 100644 --- a/app/views/referential_vehicle_journeys/index.html.slim +++ b/app/views/referential_vehicle_journeys/index.html.slim @@ -25,28 +25,28 @@                    link_to: lambda do |vehicle_journey| \                      vehicle_journey.published_journey_name ? referential_line_route_vehicle_journeys_path(@referential, vehicle_journey.route.line, vehicle_journey.route) : '' \                    end, \ -                  sortable: false \ +                  sortable: true \                  ), \                  TableBuilderHelper::Column.new( \                    key: :line, \                    attribute: Proc.new {|v| v.route.line.name}, \ -                  sortable: false \ +                  sortable: true \                  ), \                  TableBuilderHelper::Column.new( \                    key: :route, \                    attribute: Proc.new {|v| v.route.name}, \ -                  sortable: false \ +                  sortable: true \                  ), \                  TableBuilderHelper::Column.new( \                    key: :departure_time, \                    attribute: Proc.new {|v| v.vehicle_journey_at_stops.first&.departure }, \ -                  sortable: false \ +                  sortable: true \                  ), \                  @filters_stop_areas&.map{|s| table_builder_column_for_stop_area(s)},                  TableBuilderHelper::Column.new( \                    key: :arrival_time, \                    attribute: Proc.new {|v| v.vehicle_journey_at_stops.last&.arrival }, \ -                  sortable: false \ +                  sortable: true \                  ), \                ].flatten.compact,                cls: 'table has-filter has-search' diff --git a/app/views/referentials/_overview.html.slim b/app/views/referentials/_overview.html.slim index 143784800..539c25fd4 100644 --- a/app/views/referentials/_overview.html.slim +++ b/app/views/referentials/_overview.html.slim @@ -9,7 +9,7 @@                span.fa.fa-search          .form-group.togglable            = f.label Chouette::Line.human_attribute_name(:company_id), required: false, class: 'control-label' -          = f.input :company_id_eq_any, collection: overview.referential_lines.map(&:company).uniq.sort_by(&:name), as: :check_boxes, label: false, label_method: lambda{|l| ("<span>" + l.name + "</span>").html_safe}, required: false, wrapper_html: { class: 'checkbox_list'} +          = f.input :company_id_eq_any, collection: overview.referential_lines.map(&:company).compact.uniq.sort_by(&:name), as: :check_boxes, label: false, label_method: lambda{|l| ("<span>" + l.name + "</span>").html_safe}, required: false, wrapper_html: { class: 'checkbox_list'}          .form-group.togglable            = f.label Chouette::Line.human_attribute_name(:transport_mode), required: false, class: 'control-label' diff --git a/app/views/vehicle_journeys/show.rabl b/app/views/vehicle_journeys/show.rabl index 546c851a4..bb26ce797 100644 --- a/app/views/vehicle_journeys/show.rabl +++ b/app/views/vehicle_journeys/show.rabl @@ -23,6 +23,12 @@ end  child(:time_tables, :object_root => false) do |time_tables|    attributes :id, :objectid, :comment, :color +  node(:days) do |tt| +    tt.display_day_types +  end +  node(:bounding_dates) do |tt| +    tt.presenter.time_table_bounding +  end    child(:calendar) do      attributes :id, :name, :date_ranges, :dates, :shared    end diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb index 2d06fb88b..fc652a2da 100644 --- a/config/initializers/apartment.rb +++ b/config/initializers/apartment.rb @@ -81,6 +81,7 @@ Apartment.configure do |config|      'ComplianceCheckMessage',      'Merge',      'CustomField', +    'SimpleImporter',    ]    # use postgres schemas? diff --git a/config/secrets.yml.docker b/config/secrets.yml.docker index 7e7808070..f9bbf5fa0 100644 --- a/config/secrets.yml.docker +++ b/config/secrets.yml.docker @@ -14,4 +14,4 @@    secret_key_base: <%= ENV.fetch 'SECRET_KEY_BASE', 'change_this_string_for_something_more_secure' %>    api_endpoint: <%= ENV.fetch 'IEV_API_ENDPOINT', 'http://iev:8080/chouette_iev/' %>    api_token: <%= ENV.fetch 'IEV_API_TOKEN', 'change this according to IEV configuration' %> -  newrelic_licence_key: <%= ENF.fetch 'NR_LICENCE_KEY', 'will_not_work' %> +  newrelic_licence_key: <%= ENV.fetch 'NR_LICENCE_KEY', 'will_not_work' %> diff --git a/db/migrate/20180129210928_create_simple_importers.rb b/db/migrate/20180129210928_create_simple_importers.rb new file mode 100644 index 000000000..c2a918900 --- /dev/null +++ b/db/migrate/20180129210928_create_simple_importers.rb @@ -0,0 +1,10 @@ +class CreateSimpleImporters < ActiveRecord::Migration +  def change +    create_table :simple_importers do |t| +      t.string :configuration_name +      t.string :filepath +      t.string :status +      t.json :journal +    end +  end +end diff --git a/db/schema.rb b/db/schema.rb index 596682ce8..f77961f8d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -15,9 +15,10 @@ ActiveRecord::Schema.define(version: 20180202170009) do    # These are extensions that must be enabled in order to support this database    enable_extension "plpgsql" -  enable_extension "postgis"    enable_extension "hstore" +  enable_extension "postgis"    enable_extension "unaccent" +  enable_extension "objectid"    create_table "access_links", id: :bigserial, force: :cascade do |t|      t.integer  "access_point_id",                        limit: 8 @@ -119,6 +120,7 @@ ActiveRecord::Schema.define(version: 20180202170009) do      t.datetime "updated_at"      t.date     "end_date"      t.string   "date_type" +    t.string   "mode"    end    add_index "clean_ups", ["referential_id"], name: "index_clean_ups_on_referential_id", using: :btree @@ -724,6 +726,13 @@ ActiveRecord::Schema.define(version: 20180202170009) do      t.integer "line_id",      limit: 8    end +  create_table "simple_importers", id: :bigserial, force: :cascade do |t| +    t.string "configuration_name" +    t.string "filepath" +    t.string "status" +    t.json   "journal" +  end +    create_table "stop_area_referential_memberships", id: :bigserial, force: :cascade do |t|      t.integer "organisation_id",          limit: 8      t.integer "stop_area_referential_id", limit: 8 diff --git a/lib/tasks/imports.rake b/lib/tasks/imports.rake index 02e32fd3d..b91ff7efb 100644 --- a/lib/tasks/imports.rake +++ b/lib/tasks/imports.rake @@ -1,3 +1,5 @@ +require 'csv' +  namespace :import do    desc "Notify parent imports when children finish"    task notify_parent: :environment do @@ -8,4 +10,99 @@ namespace :import do    task netex_abort_old: :environment do      NetexImport.abort_old    end + +  def importer_output_to_csv importer +    filepath = "./#{importer.configuration_name}_#{Time.now.strftime "%y%m%d%H%M"}_out.csv" +    cols = %w(line kind event message error) +    if importer.reload.journal.size > 0 +      keys = importer.journal.first["row"].map(&:first) +      CSV.open(filepath, "w") do |csv| +        csv << cols + keys +        importer.journal.each do |j| +          csv << cols.map{|c| j[c]} + j["row"].map(&:last) +        end +      end +      puts "Import Output written in #{filepath}" +    end +  end + +  desc "import the given file with the corresponding importer" +  task :import, [:configuration_name, :filepath, :referential_id] => :environment do |t, args| +    importer = SimpleImporter.create configuration_name: args[:configuration_name], filepath: args[:filepath] +    if args[:referential_id].present? +      referential = Referential.find args[:referential_id] +      importer.configure do |config| +        config.add_value :referential, referential +        config.context = {referential: referential} +      end +    end +    puts "\e[33m***\e[0m Start importing" +    begin +      importer.import(verbose: true) +    rescue Interrupt +      raise +    ensure +      puts "\n\e[33m***\e[0m Import done, status: " + (importer.status == "success" ? "\e[32m" : "\e[31m" ) + (importer.status || "") + "\e[0m" +      importer_output_to_csv importer +    end +  end + +  desc "import the given file with the corresponding importer in the given StopAreaReferential" +  task :import_in_stop_area_referential, [:referential_id, :configuration_name, :filepath] => :environment do |t, args| +    referential = StopAreaReferential.find args[:referential_id] +    importer = SimpleImporter.create configuration_name: args[:configuration_name], filepath: args[:filepath] +    importer.configure do |config| +      config.add_value :stop_area_referential, referential +      config.context = {stop_area_referential: referential} +    end +    puts "\e[33m***\e[0m Start importing" +    begin +      importer.import(verbose: true) +    rescue Interrupt +      raise +    ensure +      puts "\n\e[33m***\e[0m Import done, status: " + (importer.status == "success" ? "\e[32m" : "\e[31m" ) + (importer.status || "") + "\e[0m" +      importer_output_to_csv importer +    end +  end + +  desc "import the given routes files" +  task :import_routes, [:referential_id, :configuration_name, :mapping_filepath, :filepath] => :environment do |t, args| +    referential = Referential.find args[:referential_id] +    referential.switch +    stop_area_referential = referential.stop_area_referential +    importer = SimpleImporter.create configuration_name: args[:configuration_name], filepath: args[:filepath] +    importer.configure do |config| +      config.add_value :stop_area_referential, referential +      config.context = {stop_area_referential: stop_area_referential, mapping_filepath: args[:mapping_filepath]} +    end +    puts "\e[33m***\e[0m Start importing" +    begin +      importer.import(verbose: true) +    rescue Interrupt +      raise +    ensure +      puts "\n\e[33m***\e[0m Import done, status: " + (importer.status == "success" ? "\e[32m" : "\e[31m" ) + (importer.status || "") + "\e[0m" +      importer_output_to_csv importer +    end +  end + +  desc "import the given file with the corresponding importer in the given LineReferential" +  task :import_in_line_referential, [:referential_id, :configuration_name, :filepath] => :environment do |t, args| +    referential = LineReferential.find args[:referential_id] +    importer = SimpleImporter.create configuration_name: args[:configuration_name], filepath: args[:filepath] +    importer.configure do |config| +      config.add_value :line_referential, referential +      config.context = {line_referential: referential} +    end +    puts "\e[33m***\e[0m Start importing" +    begin +      importer.import(verbose: true) +    rescue Interrupt +      raise +    ensure +      puts "\n\e[33m***\e[0m Import done, status: " + (importer.status == "success" ? "\e[32m" : "\e[31m" ) + (importer.status || "") + "\e[0m" +      importer_output_to_csv importer +    end +  end  end diff --git a/spec/fixtures/simple_importer/lines_mapping.csv b/spec/fixtures/simple_importer/lines_mapping.csv new file mode 100644 index 000000000..b26d0ab59 --- /dev/null +++ b/spec/fixtures/simple_importer/lines_mapping.csv @@ -0,0 +1,11 @@ +id;timetable_route_id;route_name;stop_sequence;stop_distance;station_code;station_name;border;Ligne Chouette;Transporteur
 +3354;1136;Paris centre - Bercy > Lille > Londres;1;0;XPB;Paris City Center - Bercy;f;Paris <> Londres - OUIBUS;OUIBUS
 +3355;1136;Paris centre - Bercy > Lille > Londres;2;232;XDB;Lille;f;Paris <> Londres - OUIBUS;OUIBUS
 +3749;1136;Paris centre - Bercy > Lille > Londres;3;350;COF;Coquelles - France;t;Paris <> Londres - OUIBUS;OUIBUS
 +4772;1136;Paris centre - Bercy > Lille > Londres;4;350;COU;Coquelles - UK;t;Paris <> Londres - OUIBUS;OUIBUS
 +3357;1136;Paris centre - Bercy > Lille > Londres;5;527;ZEP;London;f;Paris <> Londres - OUIBUS;OUIBUS
 +3358;1137;Londres > Lille > Paris centre - Bercy;1;0;ZEP;London;f;Paris <> Londres - OUIBUS;OUIBUS
 +3559;1137;Londres > Lille > Paris centre - Bercy;2;177;COU;Coquelles - UK;t;Paris <> Londres - OUIBUS;OUIBUS
 +3743;1137;Londres > Lille > Paris centre - Bercy;3;177;COF;Coquelles - France;t;Paris <> Londres - OUIBUS;OUIBUS
 +3360;1137;Londres > Lille > Paris centre - Bercy;4;295;XDB;Lille;f;Paris <> Londres - OUIBUS;OUIBUS
 +3361;1137;Londres > Lille > Paris centre - Bercy;5;527;XPB;Paris City Center - Bercy;f;Paris <> Londres - OUIBUS;OUIBUS
 diff --git a/spec/fixtures/simple_importer/stop_area.csv b/spec/fixtures/simple_importer/stop_area.csv new file mode 100644 index 000000000..9361d022b --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area.csv @@ -0,0 +1,2 @@ +name;lat;long;type;street_name +Nom du Stop;45.00;12;ZDEP;99 rue des Poissonieres diff --git a/spec/fixtures/simple_importer/stop_area_full.csv b/spec/fixtures/simple_importer/stop_area_full.csv new file mode 100644 index 000000000..250caab30 --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area_full.csv @@ -0,0 +1,3 @@ +"id";"station_code";"uic_code";"country_code";"province";"district";"county";"station_name";"inactive";"change_timestamp";"longitude";"latitude";"parent_station_code";"additional_info";"external_reference";"timezone";"address";"postal_code";"city" +5669;"PAR";"PAR";"FRA";"";"";"";"Paris - All stations";f;"2017-07-17 11:56:53.138";2.35222190000002;48.856614;"";"";"{""Région"":""Ile-de-France""}";"Europe/Paris";"";"";"" +5748;"XED";"XED";"FRA";"";"";"";"Paris MLV";t;"2017-05-29 11:24:34.575";2.783409;48.870569;"PAR";"";"{""Région"":""Ile-de-France""}";"Europe/Paris";"";"";"" diff --git a/spec/fixtures/simple_importer/stop_area_full_reverse.csv b/spec/fixtures/simple_importer/stop_area_full_reverse.csv new file mode 100644 index 000000000..9ea15f6cc --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area_full_reverse.csv @@ -0,0 +1,3 @@ +"id";"station_code";"uic_code";"country_code";"province";"district";"county";"station_name";"inactive";"change_timestamp";"longitude";"latitude";"parent_station_code";"additional_info";"external_reference";"timezone";"address";"postal_code";"city" +5748;"XED";"XED";"FRA";"";"";"";"Paris MLV";t;"2017-05-29 11:24:34.575";2.783409;48.870569;"PAR";"";"{""Région"":""Ile-de-France""}";"Europe/Paris";"";"";"" +5669;"PAR";"PAR";"FRA";"";"";"";"Paris - All stations";f;"2017-07-17 11:56:53.138";2.35222190000002;48.856614;"";"";"{""Région"":""Ile-de-France""}";"Europe/Paris";"";"";"" diff --git a/spec/fixtures/simple_importer/stop_area_incomplete.csv b/spec/fixtures/simple_importer/stop_area_incomplete.csv new file mode 100644 index 000000000..9b11aa02c --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area_incomplete.csv @@ -0,0 +1,3 @@ +name;lat;long;type;street_name +Foo;45.00;12;ZDEP +;45.00;12;ZDEP diff --git a/spec/fixtures/simple_importer/stop_area_missing_street_name.csv b/spec/fixtures/simple_importer/stop_area_missing_street_name.csv new file mode 100644 index 000000000..aa845c3f5 --- /dev/null +++ b/spec/fixtures/simple_importer/stop_area_missing_street_name.csv @@ -0,0 +1,2 @@ +name;lat;long;type;foo +Nom du Stop;45.00;12;ZDEP;blabla diff --git a/spec/fixtures/simple_importer/stop_points_full.csv b/spec/fixtures/simple_importer/stop_points_full.csv new file mode 100644 index 000000000..9c5b3c1b7 --- /dev/null +++ b/spec/fixtures/simple_importer/stop_points_full.csv @@ -0,0 +1,11 @@ +"id";"timetable_route_id";"route_name";"stop_sequence";"stop_distance";"station_code";"station_name";"border"
 +3354;1136;"Paris centre - Bercy > Lille > Londres";1;0;"XPB";"Paris City Center - Bercy";f
 +3355;1136;"Paris centre - Bercy > Lille > Londres";2;232;"XDB";"Lille";f
 +3749;1136;"Paris centre - Bercy > Lille > Londres";3;350;"COF";"Coquelles - France";t
 +4772;1136;"Paris centre - Bercy > Lille > Londres";4;350;"COU";"Coquelles - UK";t
 +3357;1136;"Paris centre - Bercy > Lille > Londres";5;527;"ZEP";"London";f
 +3358;1137;"Londres > Lille > Paris centre - Bercy";1;0;"ZEP";"London";f
 +3559;1137;"Londres > Lille > Paris centre - Bercy";2;177;"COU";"Coquelles - UK";t
 +3743;1137;"Londres > Lille > Paris centre - Bercy";3;177;"COF";"Coquelles - France";t
 +3360;1137;"Londres > Lille > Paris centre - Bercy";4;295;"XDB";"Lille";f
 +3361;1137;"Londres > Lille > Paris centre - Bercy";5;527;"XPB";"Paris City Center - Bercy";f
 diff --git a/spec/javascript/vehicle_journeys/components/__snapshots__/VehicleJourneys_spec.js.snap b/spec/javascript/vehicle_journeys/components/__snapshots__/VehicleJourneys_spec.js.snap index cdd34cbbd..818845ec8 100644 --- a/spec/javascript/vehicle_journeys/components/__snapshots__/VehicleJourneys_spec.js.snap +++ b/spec/javascript/vehicle_journeys/components/__snapshots__/VehicleJourneys_spec.js.snap @@ -33,6 +33,9 @@ exports[`stopPointHeader should display the city name 1`] = `            <div>              calendrier            </div> +          <div +            className="detailed-timetables hidden" +          />          </div>          <div            className="td" @@ -123,6 +126,9 @@ exports[`stopPointHeader with the "long_distance_routes" feature should display            <div>              calendrier            </div> +          <div +            className="detailed-timetables hidden" +          />          </div>          <div            className="td" diff --git a/spec/javascript/vehicle_journeys/reducers/vehicleJourneys_spec.js b/spec/javascript/vehicle_journeys/reducers/vehicleJourneys_spec.js index 0d7612a80..389c60add 100644 --- a/spec/javascript/vehicle_journeys/reducers/vehicleJourneys_spec.js +++ b/spec/javascript/vehicle_journeys/reducers/vehicleJourneys_spec.js @@ -152,7 +152,7 @@ describe('vehicleJourneys reducer', () => {        },        departure_time : {          hour: 23, -        minute: 2 +        minute: 12        },        departure_day_offset: -1,        arrival_day_offset: -1, @@ -178,11 +178,11 @@ describe('vehicleJourneys reducer', () => {        delta : 0,        arrival_time : {          hour: 0, -        minute: 32 +        minute: 42        },        departure_time : {          hour: 0, -        minute: 32 +        minute: 42        },        stop_point_objectid: 'test-4',        stop_area_cityname: 'city', @@ -219,7 +219,7 @@ describe('vehicleJourneys reducer', () => {          type: 'ADD_VEHICLEJOURNEY',          data: fakeData,          selectedJourneyPattern: fakeSelectedJourneyPattern, -        stopPointsList: [{object_id: 'test-1', city_name: 'city', stop_area_id: 1, id: 1, time_zone_offset: 0}, {object_id: 'test-2', city_name: 'city', stop_area_id: 2, id: 2, time_zone_offset: -3600}, {object_id: 'test-3', city_name: 'city', stop_area_id: 3, id: 3, time_zone_offset: 0}, {object_id: 'test-4', city_name: 'city', stop_area_id: 4, id: 4, time_zone_offset: 0}], +        stopPointsList: [{object_id: 'test-1', city_name: 'city', stop_area_id: 1, id: 1, time_zone_offset: 0, waiting_time: null}, {object_id: 'test-2', city_name: 'city', stop_area_id: 2, id: 2, time_zone_offset: -3600, waiting_time: 10}, {object_id: 'test-3', city_name: 'city', stop_area_id: 3, id: 3, time_zone_offset: 0, waiting_time: 20}, {object_id: 'test-4', city_name: 'city', stop_area_id: 4, id: 4, time_zone_offset: 0}],          selectedCompany: fakeSelectedCompany        })      ).toEqual([{ diff --git a/spec/models/chouette/journey_pattern_spec.rb b/spec/models/chouette/journey_pattern_spec.rb index 19a74a0e7..7c767e4d1 100644 --- a/spec/models/chouette/journey_pattern_spec.rb +++ b/spec/models/chouette/journey_pattern_spec.rb @@ -71,6 +71,30 @@ describe Chouette::JourneyPattern, :type => :model do      end    end +  describe "set_distances" do +    let(:journey_pattern) { create :journey_pattern } +    let(:distances){ [] } +    it "should raise an error" do +      expect{journey_pattern.set_distances(distances)}.to raise_error +    end + +    context "with consistent data" do +      let(:distances){ [0, 100, "200", 500, 1000] } + +      it "should set costs" do +        expect{journey_pattern.set_distances(distances)}.to_not raise_error +        start, stop = journey_pattern.stop_points[0..1] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 100 +        start, stop = journey_pattern.stop_points[1..2] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 100 +        start, stop = journey_pattern.stop_points[2..3] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 300 +        start, stop = journey_pattern.stop_points[3..4] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 500 +      end +    end +  end +    describe "state_update" do      def journey_pattern_to_state jp        jp.attributes.slice('name', 'published_name', 'registration_number').tap do |item| diff --git a/spec/models/chouette/vehicle_journey_at_stop_spec.rb b/spec/models/chouette/vehicle_journey_at_stop_spec.rb index a97559a0c..f79d19c88 100644 --- a/spec/models/chouette/vehicle_journey_at_stop_spec.rb +++ b/spec/models/chouette/vehicle_journey_at_stop_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Chouette::VehicleJourneyAtStop, type: :model do      context '#checksum_attributes' do        it 'should return attributes' do -        expected = [at_stop.departure_time.to_s(:time), at_stop.arrival_time.to_s(:time)] +        expected = [at_stop.departure_time.utc.to_s(:time), at_stop.arrival_time.utc.to_s(:time)]          expected << at_stop.departure_day_offset.to_s          expected << at_stop.arrival_day_offset.to_s          expect(at_stop.checksum_attributes).to include(*expected) diff --git a/spec/models/simple_importer_spec.rb b/spec/models/simple_importer_spec.rb new file mode 100644 index 000000000..60d7b7882 --- /dev/null +++ b/spec/models/simple_importer_spec.rb @@ -0,0 +1,394 @@ +RSpec.describe SimpleImporter do +  describe "#define" do +    context "with an incomplete configuration" do + +      it "should raise an error" do +        expect do +          SimpleImporter.define :foo +        end.to raise_error +      end +    end +    context "with a complete configuration" do +      before do +        SimpleImporter.define :foo do |config| +          config.model = "example" +        end +      end + +      it "should define an importer" do +        expect{SimpleImporter.find_configuration(:foo)}.to_not raise_error +        expect{SimpleImporter.new(configuration_name: :foo, filepath: "")}.to_not raise_error +        expect{SimpleImporter.find_configuration(:bar)}.to raise_error +        expect{SimpleImporter.new(configuration_name: :bar, filepath: "")}.to raise_error +        expect{SimpleImporter.create(configuration_name: :foo, filepath: "")}.to change{SimpleImporter.count}.by 1 +      end +    end +  end + +  describe "#import" do +    let(:importer){ importer = SimpleImporter.new(configuration_name: :test, filepath: filepath) } +    let(:filepath){ fixtures_path 'simple_importer', filename } +    let(:filename){ "stop_area.csv" } +    let(:stop_area_referential){ create(:stop_area_referential, objectid_format: :stif_netex) } + +    before(:each) do +      SimpleImporter.define :test do |config| +        config.model = Chouette::StopArea +        config.separator = ";" +        config.key = "name" +        config.add_column :name +        config.add_column :lat, attribute: :latitude +        config.add_column :lat, attribute: :longitude, value: ->(raw){ raw.to_f + 1 } +        config.add_column :type, attribute: :area_type, value: ->(raw){ raw&.downcase } +        config.add_column :street_name +        config.add_column :stop_area_referential, value: stop_area_referential +        config.add_value  :kind, :commercial +      end +    end + +    it "should import the given file" do +      expect{importer.import verbose: false}.to change{Chouette::StopArea.count}.by 1 +      expect(importer.status).to eq "success" +      stop = Chouette::StopArea.last +      expect(stop.name).to eq "Nom du Stop" +      expect(stop.latitude).to eq 45.00 +      expect(stop.longitude).to eq 46.00 +      expect(stop.area_type).to eq "zdep" +      expect(importer.reload.journal.last["event"]).to eq("creation") +    end + +    context "when overriding configuration" do +      before(:each){ +        importer.configure do |config| +          config.add_value :latitude, 88 +        end +      } + +      it "should import the given file and not mess with the global configuration" do +        expect{importer.import}.to change{Chouette::StopArea.count}.by 1 +        expect(importer.status).to eq "success" +        stop = Chouette::StopArea.last +        expect(stop.latitude).to eq 88 +        importer = SimpleImporter.new(configuration_name: :test, filepath: filepath) +        expect{importer.import}.to change{Chouette::StopArea.count}.by 0 +        expect(stop.reload.latitude).to eq 45 +      end +    end + +    context "with an already existing record" do +      let(:filename){ "stop_area.csv" } +      before(:each){ +        create :stop_area, name: "Nom du Stop" +      } +      it "should only update the record" do +        expect{importer.import}.to change{Chouette::StopArea.count}.by 0 +        expect(importer.status).to eq "success" +        stop = Chouette::StopArea.last +        expect(stop.name).to eq "Nom du Stop" +        expect(stop.latitude).to eq 45.00 +        expect(stop.longitude).to eq 46.00 +        expect(stop.area_type).to eq "zdep" +        expect(importer.reload.journal.last["event"]).to eq("update") +      end + +      context "in another scope" do +        before(:each) do +          ref = create(:stop_area_referential) +          importer.configure do |config| +            config.context = { stop_area_referential: ref } +            config.scope = ->{ context[:stop_area_referential].stop_areas } +          end +        end + +        it "should create the record" do +          expect{importer.import verbose: false}.to change{Chouette::StopArea.count}.by 1 +          expect(importer.status).to eq "success" +        end +      end +    end + +    context "with a missing column" do +      let(:filename){ "stop_area_missing_street_name.csv" } +      it "should set an error message" do +        expect{importer.import(verbose: false)}.to_not raise_error +        expect(importer.status).to eq "success_with_warnings" +        expect(importer.reload.journal.first["event"]).to eq("column_not_found") +      end +    end + +    context "with an incomplete dataset" do +      let(:filename){ "stop_area_incomplete.csv" } +      it "should fail" do +        expect{importer.import}.to_not raise_error +        expect(importer.status).to eq "failed" +        expect(importer.reload.journal.last["message"]).to eq({"name" => ["doit être rempli(e)"]}) +      end + +      it "should be transactional" do +        expect{importer.import}.to_not change {Chouette::StopArea.count} +      end +    end + +    context "with a wrong filepath" do +      let(:filename){ "not_found.csv" } +      it "should fail" do +        expect{importer.import}.to_not raise_error +        expect(importer.status).to eq "failed" +        expect(importer.reload.journal.first["message"]).to eq "File not found: #{importer.filepath}" +      end +    end + +    context "with a custom behaviour" do +      let!(:present){ create :stop_area, name: "Nom du Stop", stop_area_referential: stop_area_referential } +      let!(:missing){ create :stop_area, name: "Another", stop_area_referential: stop_area_referential } +      before(:each){ +        importer.configure do |config| +          config.before do |importer| +            stop_area_referential.stop_areas.each &:deactivate! +          end + +          config.before(:each_save) do |importer, stop_area| +            stop_area.activate! +          end +        end +      } + +      it "should disable all missing areas" do +        expect{importer.import}.to change{Chouette::StopArea.count}.by 0 +        expect(present.reload.activated?).to be_truthy +        expect(missing.reload.activated?).to be_falsy +      end + +      context "with an error" do +        let(:filename){ "stop_area_incomplete.csv" } +        it "should do nothing" do +          expect{importer.import}.to_not change {Chouette::StopArea.count} +          expect(present.reload.activated?).to be_truthy +          expect(missing.reload.activated?).to be_truthy +        end +      end +    end + +    context "with a full file" do +      let(:filename){ "stop_area_full.csv" } +      let!(:missing){ create :stop_area, name: "Another", stop_area_referential: stop_area_referential } + +      before(:each) do +        SimpleImporter.define :test do |config| +          config.model = Chouette::StopArea +          config.separator = ";" +          config.key = "station_code" +          config.add_column :station_code, attribute: :registration_number +          config.add_column :country_code +          config.add_column :station_name, attribute: :name +          config.add_column :inactive, attribute: :deleted_at, value: ->(raw){ raw == "t" ? Time.now : nil } +          config.add_column :change_timestamp, attribute: :updated_at +          config.add_column :longitude +          config.add_column :latitude +          config.add_column :parent_station_code, attribute: :parent, value: ->(raw){ raw.present? && resolve(:station_code, raw){|value| Chouette::StopArea.find_by(registration_number: value) } } +          config.add_column :parent_station_code, attribute: :area_type, value: ->(raw){ raw.present? ? "zdep" : "gdl" } +          config.add_column :timezone, attribute: :time_zone +          config.add_column :address, attribute: :street_name +          config.add_column :postal_code, attribute: :zip_code +          config.add_column :city, attribute: :city_name +          config.add_value  :stop_area_referential_id, stop_area_referential.id +          config.add_value  :long_lat_type, "WGS84" +          config.add_value  :kind, :commercial +          config.before do |importer| +            stop_area_referential.stop_areas.each &:deactivate! +          end + +          config.before(:each_save) do |importer, stop_area| +            stop_area.activate +          end +        end +      end + +      it "should import the given file" do +        expect{importer.import(verbose: false)}.to change{Chouette::StopArea.count}.by 2 +        expect(importer.status).to eq "success" +        first = Chouette::StopArea.find_by registration_number: "PAR" +        last = Chouette::StopArea.find_by registration_number: "XED" + +        expect(last.parent).to eq first +        expect(first.area_type).to eq "gdl" +        expect(last.area_type).to eq "zdep" +        expect(first.long_lat_type).to eq "WGS84" +        expect(first.activated?).to be_truthy +        expect(last.activated?).to be_truthy +        expect(missing.reload.activated?).to be_falsy +      end + +      context "with a relation in reverse order" do +        let(:filename){ "stop_area_full_reverse.csv" } + +        it "should import the given file" do +          expect{importer.import}.to change{Chouette::StopArea.count}.by 2 +          expect(importer.status).to eq "success" +          first = Chouette::StopArea.find_by registration_number: "XED" +          last = Chouette::StopArea.find_by registration_number: "PAR" +          expect(first.parent).to eq last +        end +      end +    end + +    context "with a specific importer" do +      let(:filename){ "stop_points_full.csv" } + +      before(:each) do +        create :line, name: "Paris <> Londres - OUIBUS" + +        SimpleImporter.define :test do |config| +          config.model = Chouette::Route +          config.separator = ";" +          config.context = {stop_area_referential: stop_area_referential} + +          config.before do |importer| +            mapping = {} +            path = Rails.root + "spec/fixtures/simple_importer/lines_mapping.csv" +            CSV.foreach(path, importer.configuration.csv_options) do |row| +              if row["Ligne Chouette"].present? +                mapping[row["timetable_route_id"]] ||= Chouette::Line.find_by(name: importer.encode_string(row["Ligne Chouette"])) +              end +            end +            importer.context[:mapping] = mapping +          end + +          config.custom_handler do |row| +            line = nil +            fail_with_error "MISSING LINE FOR ROUTE: #{encode_string row["route_name"]}" do +              line = context[:mapping][row["timetable_route_id"]] +              raise unless line +            end +            @current_record = Chouette::Route.find_or_initialize_by number: row["timetable_route_id"] +            @current_record.name = encode_string row["route_name"] +            @current_record.published_name = encode_string row["route_name"] + +            @current_record.line = line +            if @prev_route != @current_record +              if @prev_route && @prev_route.valid? +                journey_pattern = @prev_route.full_journey_pattern +                fail_with_error "WRONG DISTANCES FOR ROUTE #{@prev_route.name} (#{@prev_route.number}): #{@distances.count} distances for #{@prev_route.stop_points.count} stops" do +                  journey_pattern.stop_points = @prev_route.stop_points +                  journey_pattern.set_distances @distances +                end +                fail_with_error ->(){ journey_pattern.errors.messages } do +                  journey_pattern.save! +                end +              end +              @distances = [] +            end +            @distances.push row["stop_distance"] +            position = row["stop_sequence"].to_i - 1 + +            stop_area = context[:stop_area_referential].stop_areas.where(registration_number: row["station_code"]).last +            unless stop_area +              stop_area = Chouette::StopArea.new registration_number: row["station_code"] +              stop_area.name = row["station_name"] +              stop_area.kind = row["border"] == "f" ? :commercial : :non_commercial +              stop_area.area_type = row["border"] == "f" ? :zdep : :border +              stop_area.stop_area_referential = context[:stop_area_referential] +              fail_with_error ->{p stop_area; "UNABLE TO CREATE STOP_AREA: #{stop_area.errors.messages}" }, abort_row: true do +                stop_area.save! +              end +            end +            stop_point = @current_record.stop_points.find_by(stop_area_id: stop_area.id) +            if stop_point +              stop_point.set_list_position position +            else +              stop_point = @current_record.stop_points.build(stop_area_id: stop_area.id, position: position) +              stop_point.for_boarding = :normal +              stop_point.for_alighting = :normal +            end + +            @prev_route = @current_record +          end + +          config.after(:each_save) do |importer, route| +            opposite_route_name = route.name.split(" > ").reverse.join(' > ') +            opposite_route = Chouette::Route.where(name: opposite_route_name).where('id < ?', route.id).last +            if opposite_route && opposite_route.line == route.line +              route.update_attribute :wayback, :inbound +              opposite_route.update_attribute :wayback, :outbound +              route.update_attribute :opposite_route_id, opposite_route.id +              opposite_route.update_attribute :opposite_route_id, route.id +            end +          end + +          config.after do |importer| +            prev_route = importer.instance_variable_get "@prev_route" +            if prev_route && prev_route.valid? +              journey_pattern = prev_route.full_journey_pattern +              importer.fail_with_error "WRONG DISTANCES FOR ROUTE #{prev_route.name}: #{importer.instance_variable_get("@distances").count} distances for #{prev_route.stop_points.count} stops" do +                journey_pattern.set_distances importer.instance_variable_get("@distances") +                journey_pattern.stop_points = prev_route.stop_points +              end +              importer.fail_with_error ->(){ journey_pattern.errors.messages } do +                journey_pattern.save! +              end +            end +          end +        end +      end + +      it "should import the given file" do +        routes_count = Chouette::Route.count +        journey_pattern_count = Chouette::JourneyPattern.count +        stop_areas_count = Chouette::StopArea.count + +        expect{importer.import(verbose: false)}.to change{Chouette::StopPoint.count}.by 10 +        expect(importer.status).to eq "success" +        expect(Chouette::Route.count).to eq routes_count + 2 +        expect(Chouette::JourneyPattern.count).to eq journey_pattern_count + 2 +        expect(Chouette::StopArea.count).to eq stop_areas_count + 5 +        route = Chouette::Route.find_by number: 1136 +        expect(route.stop_areas.count).to eq 5 +        expect(route.opposite_route).to eq Chouette::Route.find_by(number: 1137) +        journey_pattern = route.full_journey_pattern +        expect(journey_pattern.stop_areas.count).to eq 5 +        start, stop = journey_pattern.stop_points[0..1] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 232 +        start, stop = journey_pattern.stop_points[1..2] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 118 +        start, stop = journey_pattern.stop_points[2..3] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 0 +        start, stop = journey_pattern.stop_points[3..4] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 177 + +        route = Chouette::Route.find_by number: 1137 +        expect(route.opposite_route).to eq Chouette::Route.find_by(number: 1136) +        expect(route.stop_areas.count).to eq 5 +        journey_pattern = route.full_journey_pattern +        expect(journey_pattern.stop_areas.count).to eq 5 +        start, stop = journey_pattern.stop_points[0..1] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 177 +        start, stop = journey_pattern.stop_points[1..2] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 0 +        start, stop = journey_pattern.stop_points[2..3] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 118 +        start, stop = journey_pattern.stop_points[3..4] +        expect(journey_pattern.costs_between(start, stop)[:distance]).to eq 232 + +        stop_area = Chouette::StopArea.where(registration_number: "XPB").last +        expect(stop_area.kind).to eq :commercial +        expect(stop_area.area_type).to eq :zdep + +        stop_area = Chouette::StopArea.where(registration_number: "XDB").last +        expect(stop_area.kind).to eq :commercial +        expect(stop_area.area_type).to eq :zdep + +        stop_area = Chouette::StopArea.where(registration_number: "COF").last +        expect(stop_area.kind).to eq :non_commercial +        expect(stop_area.area_type).to eq :border + +        stop_area = Chouette::StopArea.where(registration_number: "COU").last +        expect(stop_area.kind).to eq :non_commercial +        expect(stop_area.area_type).to eq :border + +        stop_area = Chouette::StopArea.where(registration_number: "ZEP").last +        expect(stop_area.kind).to eq :commercial +        expect(stop_area.area_type).to eq :zdep +      end +    end +  end +end | 
