diff --git a/lib/dotcom/schedule_finder.ex b/lib/dotcom/schedule_finder.ex index 8b838fdf6a..07b8b97b40 100644 --- a/lib/dotcom/schedule_finder.ex +++ b/lib/dotcom/schedule_finder.ex @@ -240,6 +240,9 @@ defmodule Dotcom.ScheduleFinder do departures |> Enum.group_by(&@routes_repo.get(&1.route_id)) |> Enum.map(&departures_with_destination(&1, direction_id, stop_id)) + # If a group has fewer than 3 trips we can't calculate headways. It's also + # seldom enough that we'd rather just omit it. + |> Enum.reject(fn {_, _, times} -> length(times) <= 2 end) end @ashmont_branch_stops ~w(place-shmnl place-fldcr place-smmnl place-asmnl) diff --git a/lib/dotcom/schedule_finder/service_groups.ex b/lib/dotcom/schedule_finder/service_groups.ex new file mode 100644 index 0000000000..5e81a24251 --- /dev/null +++ b/lib/dotcom/schedule_finder/service_groups.ex @@ -0,0 +1,105 @@ +defmodule Dotcom.ScheduleFinder.ServiceGroup do + @moduledoc """ + A group of services sharing a similar timeframe or trait, such as future, + current, holiday, planned work. + """ + + alias Dotcom.ServicePatterns + + defstruct [:services, :group_label] + + @type t :: %__MODULE__{ + group_label: String.t(), + services: [ + %{ + label: String.t(), + next_date: Date.t() | nil, + now_date: Date.t() | nil, + last_service_date: Date.t() + } + ] + } + + @spec for_route(Routes.Route.id_t(), Date.t()) :: [__MODULE__.t()] + def for_route(route_id, current_date) do + route_id + |> ServicePatterns.for_route() + |> Enum.map(fn sp -> + %{ + now_date: if(current_date in sp.dates, do: current_date), + next_date: nil, + service_pattern: sp + } + end) + |> tag_next_available(current_date) + |> Enum.group_by(& &1.service_pattern.group_label) + |> Enum.sort_by(&group_sort_order/1) + |> Enum.map(&to_service_group/1) + end + + defp tag_next_available([], _), do: [] + + defp tag_next_available(service_patterns, current_date) do + if Enum.any?(service_patterns, & &1.now_date) do + service_patterns + else + next_date = + service_patterns + |> Enum.flat_map(& &1.service_pattern.dates) + |> Enum.sort(Date) + |> Enum.find(&Date.after?(&1, current_date)) + + if next_date do + Enum.map(service_patterns, &maybe_add_next_date(&1, next_date)) + else + service_patterns + end + end + end + + defp maybe_add_next_date(%{service_pattern: %{dates: dates}} = service_pattern, next_date) do + if next_date in dates do + %{service_pattern | next_date: next_date} + else + service_pattern + end + end + + defp group_sort_order({{group_key, _}, _}) do + [:current, :future, :extra, :holiday, :planned] + |> Enum.find_index(&(&1 == group_key)) + end + + defp to_service_group({{_, group_label}, patterns}) do + %__MODULE__{ + group_label: group_label, + services: + patterns + |> Enum.sort_by(&pattern_mapper(&1.service_pattern.service_label)) + |> Enum.map(fn pattern -> + Map.take(pattern, [:next_date, :now_date]) + |> Map.put(:label, pattern_label(pattern)) + |> Map.put(:last_service_date, pattern_date(pattern)) + end) + } + end + + # Sort such that typical patterns are first, in a specified ordering, followed + # by patterns with the other typicalities, ordered by date. Use label or + # description as the tie-breaker. + defp pattern_mapper({:typical, type_key, label}) do + index = + [:monday_thursday, :friday, :weekday, :saturday, :sunday, :weekend] + |> Enum.find_index(&(&1 == type_key)) + + {0, index, label} + end + + defp pattern_mapper({typicality, first_date, description}) do + {1, typicality, to_string(first_date), description} + end + + defp pattern_label(%{service_pattern: %{service_label: {_, _, label}}}), do: label + + defp pattern_date(%{service_pattern: %{dates: dates}}), do: List.last(dates) +end diff --git a/lib/dotcom/service_patterns.ex b/lib/dotcom/service_patterns.ex index 3a85dda6b8..069a2bdf9d 100644 --- a/lib/dotcom/service_patterns.ex +++ b/lib/dotcom/service_patterns.ex @@ -3,7 +3,10 @@ defmodule Dotcom.ServicePatterns do Information about transit service! """ + use Dotcom.Gettext.Sigils + alias Dotcom.Utils.ServiceDateTime + alias Services.Service @services_repo Application.compile_env!(:dotcom, :repo_modules)[:services] @@ -23,6 +26,260 @@ defmodule Dotcom.ServicePatterns do opts |> Keyword.fetch!(:route) |> @services_repo.by_route_id() - |> Enum.any?(&Services.Service.serves_date?(&1, date)) + |> Enum.any?(&Service.serves_date?(&1, date)) + end + + @spec for_route(Routes.Route.id_t()) :: [Service.t()] + def for_route(route_id) do + route_id + |> @services_repo.by_route_id() + |> Stream.reject(&(&1.typicality == :canonical)) + |> Stream.flat_map(&unwrap_multiple_holidays/1) + |> Stream.map(&add_single_date_description/1) + |> Stream.map(&adjust_planned_description/1) + |> Enum.reject(&Date.before?(&1.end_date, ServiceDateTime.service_date())) + |> dedup_identical_services() + |> dedup_similar_services() + |> to_service_pattern() + end + + defp unwrap_multiple_holidays( + %{typicality: :holiday_service, added_dates: added_dates} = service + ) + when length(added_dates) > 1 do + for added_date <- added_dates do + %{ + service + | added_dates: [added_date], + added_dates_notes: Map.take(service.added_dates_notes, [added_date]) + } + end + end + + defp unwrap_multiple_holidays(service), do: [service] + + defp add_single_date_description( + %{ + added_dates: [single_date], + added_dates_notes: added_dates_notes, + typicality: typicality + } = service + ) + when typicality in [:extra_service, :holiday_service] do + date_note = Map.get(added_dates_notes, single_date) || service.description + + formatted_date = + single_date + |> Date.from_iso8601!() + |> format_tiny_date() + + %{ + service + | description: "#{date_note}, #{formatted_date}" + } + end + + defp add_single_date_description(service), do: service + + defp format_tiny_date(date), do: Dotcom.Utils.Time.format!(date, :month_day_short) + + defp adjust_planned_description(%{typicality: :planned_disruption} = service) do + dates = + if service.start_date == service.end_date do + " (#{format_tiny_date(service.start_date)})" + else + " (#{format_tiny_date(service.start_date)} - #{format_tiny_date(service.end_date)})" + end + + Map.update!(service, :description, &(&1 <> dates)) + end + + defp adjust_planned_description(service), do: service + + defp dedup_identical_services(services) do + services + |> Enum.group_by(fn service -> + {service.start_date, service.end_date, service.valid_days, service.removed_dates, + service.added_dates} + end) + |> Enum.map(fn {_key, [service | _rest]} -> + service + end) + end + + # If we have two services A and B with the same type and typicality, + # with the date range from A's start to A's end a subset of the date + # range from B's start to B's end, either A is in the list of services + # erroneously (for example, in the case of the 39 in the fall 2019 + # rating), or A represents a special service that's not a holiday (for + # instance, the Thanksgiving-week extra service to Logan on the SL1 in + # the fall 2019 rating). + # + # However, in neither of these cases do we want to show service A. In the + # first case, we don't want to show A because it's erroneous, and in the + # second case, we don't want to show A for parity with the paper/PDF + # schedules, in which these special services are not generally called + # out. + + @spec dedup_similar_services([Service.t()]) :: [Service.t()] + defp dedup_similar_services(services) do + services + |> Enum.group_by(&{&1.type, &1.typicality, &1.rating_description}) + |> Enum.flat_map(fn {_, service_group} -> + service_group + |> drop_extra_weekday_schedule_if_friday_present() + |> then(fn services -> + Enum.reject(services, &service_completely_overlapped?(&1, services)) + end) + end) + end + + # If there's a Friday service and two overlapping weekday schedules, we want to show the Monday-Thursday one rather than the Monday-Friday one. + defp drop_extra_weekday_schedule_if_friday_present(services) do + if Enum.find(services, &Service.friday_typical_service?/1) && + Enum.find(services, &Service.monday_to_thursday_typical_service?/1) do + Enum.reject(services, &(&1.valid_days == [1, 2, 3, 4, 5])) + else + services + end + end + + defp service_completely_overlapped?(%{typicality: :holiday_service}, _), do: false + + defp service_completely_overlapped?(service, services) do + Enum.any?(services, fn other_service -> + # There's an other service that + # - starts earlier/same time as this service + # - and ends later/same time as this service + # - and covers the same valid_days as this service + other_service != service && String.contains?(service.name, other_service.name) && + Date.compare(other_service.start_date, service.start_date) != :gt && + Date.compare(other_service.end_date, service.end_date) != :lt && + Enum.all?(service.valid_days, &Enum.member?(other_service.valid_days, &1)) + end) + end + + defp to_service_pattern(services) do + services + |> Enum.map(fn service -> + %{ + service: service, + dates: Service.all_valid_dates_for_service(service), + group_label: group_label(service) + } + end) + |> merge_similar_typical() + end + + def group_label(service) do + case service.typicality do + :holiday_service -> + {:holiday, ~t"Holiday Schedules"} + + :extra_service -> + {:extra, ~t"Extra Service"} + + :planned_disruption -> + {:planned, gettext("%{rating} Planned Work", rating: service.rating_description)} + + _ -> + typical_service_group(service) + end + end + + defp typical_service_group( + %Service{ + rating_description: rating_description, + rating_end_date: rating_end_date, + rating_start_date: rating_start_date + } = service + ) do + start_date = short_format(rating_start_date) + + if Service.in_current_rating?(service) do + label = + if rating_end_date do + gettext("%{rating} Schedules, ends %{date}", + rating: rating_description, + date: short_format(rating_end_date) + ) + else + gettext("%{rating} Schedules, starts %{date}", + rating: rating_description, + date: start_date + ) + end + + {:current, label} + else + if Service.in_future_rating?(service) do + label = + gettext("%{rating} Schedules, starts %{date}", + rating: rating_description, + date: start_date + ) + + {:future, label} + else + {:other, ~t"Other Schedules"} + end + end + end + + defp short_format(date) do + case Dotcom.Utils.Time.format(date, :month_day_short) do + {:ok, value} -> value + _ -> nil + end + end + + defp merge_similar_typical(all) do + all + |> Enum.group_by(&similar_typical_items/1) + |> Enum.map(&merge_items/1) + end + + defp similar_typical_items(%{ + dates: dates, + service: %Service{typicality: :typical_service} = service + }) do + typical_groups = [ + {:monday_thursday, ~t"Monday - Thursday schedules", + fn s -> + s.type == :weekday && + (s.valid_days == [1, 2, 3, 4] || s.description =~ "Monday - Thursday") + end}, + {:friday, ~t"Friday schedules", + fn s -> + s.type == :weekday && (s.valid_days == [5] || s.description =~ "Friday") + end}, + {:weekday, ~t"Weekday schedules", fn s -> s.type == :weekday end}, + {:saturday, ~t"Saturday schedules", fn s -> s.type == :saturday end}, + {:sunday, ~t"Sunday schedules", fn s -> s.type == :sunday end}, + {:weekend, ~t"Weekend schedules", fn s -> s.type == :weekend end} + ] + + case Enum.find(typical_groups, fn {_, _, func} -> func.(service) end) do + {key, label, _} -> {:typical, key, label} + _ -> {service.typicality, List.first(dates), service.description} + end + end + + defp similar_typical_items(%{dates: dates, service: service}), + do: {service.typicality, List.first(dates), service.description} + + defp merge_items({label, [%{service: _, dates: dates, group_label: group_label}]}) do + %{service_label: label, dates: dates, group_label: group_label} + end + + defp merge_items({label, many_items}) do + merged_dates = + many_items + |> Enum.flat_map(& &1.dates) + |> Enum.uniq() + |> Enum.sort(Date) + + merged_label = List.first(many_items) |> Map.get(:group_label) + %{service_label: label, dates: merged_dates, group_label: merged_label} end end diff --git a/lib/dotcom_web/controllers/schedule/line_controller.ex b/lib/dotcom_web/controllers/schedule/line_controller.ex index 90793b41fe..4f271c5842 100644 --- a/lib/dotcom_web/controllers/schedule/line_controller.ex +++ b/lib/dotcom_web/controllers/schedule/line_controller.ex @@ -8,7 +8,6 @@ defmodule DotcomWeb.ScheduleController.LineController do alias DotcomWeb.{ScheduleView, ViewHelpers} alias Plug.Conn alias Routes.{Group, Route} - alias Services.Repo, as: ServicesRepo alias Services.Service plug(DotcomWeb.Plugs.Route) @@ -35,8 +34,6 @@ defmodule DotcomWeb.ScheduleController.LineController do end def assign_schedule_page_data(conn) do - services_fn = Map.get(conn.assigns, :services_fn, &ServicesRepo.by_route_id/1) - service_date = Map.get(conn.assigns, :date_time, Util.now()) |> Util.service_date() assign( @@ -64,7 +61,7 @@ defmodule DotcomWeb.ScheduleController.LineController do fare_link: ScheduleView.route_fare_link(conn.assigns.route), holidays: conn.assigns.holidays, route: Route.to_json_safe(conn.assigns.route), - services: services(conn.assigns.route.id, service_date, services_fn), + services: services(conn.assigns.route.id, service_date), schedule_note: ScheduleNote.new(conn.assigns.route), stops: simple_stop_map(conn), direction_id: conn.assigns.direction_id, @@ -83,78 +80,11 @@ defmodule DotcomWeb.ScheduleController.LineController do ) end - # If we have two services A and B with the same type and typicality, - # with the date range from A's start to A's end a subset of the date - # range from B's start to B's end, either A is in the list of services - # erroneously (for example, in the case of the 39 in the fall 2019 - # rating), or A represents a special service that's not a holiday (for - # instance, the Thanksgiving-week extra service to Logan on the SL1 in - # the fall 2019 rating). - # - # However, in neither of these cases do we want to show service A. In the - # first case, we don't want to show A because it's erroneous, and in the - # second case, we don't want to show A for parity with the paper/PDF - # schedules, in which these special services are not generally called - # out. - - @spec dedup_similar_services([Service.t()]) :: [Service.t()] - def dedup_similar_services(services) do - services - |> Enum.group_by(&{&1.type, &1.typicality, &1.rating_description}) - |> Enum.flat_map(fn {_, service_group} -> - service_group - |> drop_extra_weekday_schedule_if_friday_present() - |> then(fn services -> - Enum.reject(services, &service_completely_overlapped?(&1, services)) - end) - end) - end - - # If there's a Friday service and two overlapping weekday schedules, we want to show the Monday-Thursday one rather than the Monday-Friday one. - defp drop_extra_weekday_schedule_if_friday_present(services) do - if Enum.find(services, &Service.friday_typical_service?/1) && - Enum.find(services, &Service.monday_to_thursday_typical_service?/1) do - Enum.reject(services, &(&1.valid_days == [1, 2, 3, 4, 5])) - else - services - end - end - - defp service_completely_overlapped?(service, services) do - Enum.any?(services, fn other_service -> - # There's an other service that - # - starts earlier/same time as this service - # - and ends later/same time as this service - # - and covers the same valid_days as this service - other_service != service && String.contains?(service.name, other_service.name) && - Date.compare(other_service.start_date, service.start_date) != :gt && - Date.compare(other_service.end_date, service.end_date) != :lt && - Enum.all?(service.valid_days, &Enum.member?(other_service.valid_days, &1)) - end) - end - - @spec dedup_identical_services([Service.t()]) :: [Service.t()] - def dedup_identical_services(services) do - services - |> Enum.group_by(fn %{start_date: start_date, end_date: end_date, valid_days: valid_days} -> - {start_date, end_date, valid_days} - end) - |> Enum.map(fn {_key, [service | _rest]} -> - service - end) - end - @spec services(Routes.Route.id_t(), Date.t()) :: [Service.t()] - def services(route_id, service_date, services_by_route_id_fn \\ &ServicesRepo.by_route_id/1) do + def services(route_id, service_date) do route_id - |> services_by_route_id_fn.() - |> Enum.reject(fn service -> - service.typicality == :canonical || service.description =~ "(no school)" || - Date.compare(service.end_date, service_date) == :lt - end) - |> dedup_identical_services() - |> dedup_similar_services() - |> Enum.sort_by(&sort_services_by_date/1) + |> Dotcom.ServicePatterns.for_route() + |> Enum.reject(&Date.before?(&1.end_date, service_date)) |> Enum.map(&Map.put(&1, :service_date, service_date)) |> tag_default_service() end diff --git a/lib/dotcom_web/live/schedule_finder_live.ex b/lib/dotcom_web/live/schedule_finder_live.ex index 807751eaa6..63013a11bf 100644 --- a/lib/dotcom_web/live/schedule_finder_live.ex +++ b/lib/dotcom_web/live/schedule_finder_live.ex @@ -14,6 +14,7 @@ defmodule DotcomWeb.ScheduleFinderLive do import Dotcom.Utils.Time, only: [format!: 2] import DotcomWeb.RouteComponents, only: [lined_list: 1, lined_list_item: 1] + alias Dotcom.ScheduleFinder.ServiceGroup alias Dotcom.ScheduleFinder.UpcomingDepartures alias DotcomWeb.Components.Prototype alias DotcomWeb.RouteComponents @@ -38,8 +39,10 @@ defmodule DotcomWeb.ScheduleFinderLive do |> assign_new(:upcoming_departures, fn -> [] end) |> assign_new(:now, fn -> @date_time.now() end) |> assign_new(:alerts, fn -> [] end) + |> assign_new(:service_groups, fn -> [] end) |> assign_new(:loaded_trips, fn -> %{} end) - |> assign_new(:date, fn -> nil end)} + |> assign_new(:selected_service_name, fn -> "" end) + |> assign_new(:daily_schedule_date, fn -> service_date() end)} end @impl LiveView @@ -59,7 +62,7 @@ defmodule DotcomWeb.ScheduleFinderLive do
<.alert_banner alerts={@alerts} />
-

{~t"Upcoming Departures"}

+

{~t"Upcoming Departures"}

<.upcoming_departures_table :if={@stop} now={@now} @@ -80,14 +83,22 @@ defmodule DotcomWeb.ScheduleFinderLive do

{~t(Daily Schedules)}

+ <.service_picker + selected_service_name={@selected_service_name} + service_groups={@service_groups} + /> <.async_result :let={departures} :if={@stop} assign={@departures}> - <:loading>Loading daily schedules... + <:loading> +
+ <.spinner aria_label={~t"Loading schedules for selected service"} /> +
+ <:failed :let={fail}> <.error_container title={inspect(fail)}> {~t"There was a problem loading schedules"} - <%= if departures do %> + <%= if length(departures) > 0 do %> <%= if @route.type in [0, 1] do %>
<.departures_table departures={departures} route={@route} loaded_trips={@loaded_trips} /> <% end %> + <% else %> +
+ {no_service_message(@service_groups, @route, @stop)} +
<% end %>
@@ -114,14 +129,28 @@ defmodule DotcomWeb.ScheduleFinderLive do end @impl LiveView - def handle_params(%{"direction_id" => direction, "route_id" => route} = params, _uri, socket) do + def handle_params(%{"direction_id" => direction, "route_id" => route_id} = params, _uri, socket) do {direction_id, _} = Integer.parse(direction) + service_groups = ServiceGroup.for_route(route_id, service_date()) + + selected_service = + service_groups + |> Enum.flat_map(& &1.services) + |> Enum.find(%{}, &(&1.now_date || &1.next_date)) + + socket = + if Map.get(selected_service, :next_date) do + assign(socket, :daily_schedule_date, Date.to_iso8601(selected_service.next_date)) + else + socket + end {:noreply, socket - |> assign(:route, @routes_repo.get(route)) + |> assign(:route, @routes_repo.get(route_id)) |> assign(:direction_id, direction_id) - |> assign(:date, Map.get(params, "date", today())) + |> assign(:service_groups, service_groups) + |> assign(:selected_service_name, Map.get(selected_service, :label, "")) |> assign_stop(params) |> assign_alerts() |> assign_departures() @@ -134,7 +163,7 @@ defmodule DotcomWeb.ScheduleFinderLive do %{"schedule_id" => schedule_id, "stop_sequence" => stop_sequence, "trip" => trip_id}, socket ) do - date = socket.assigns.date + date = socket.assigns.daily_schedule_date loaded_trips = socket.assigns.loaded_trips if Map.get(loaded_trips, schedule_id) do @@ -148,6 +177,22 @@ defmodule DotcomWeb.ScheduleFinderLive do end end + def handle_event("select_service", %{"selected_service" => selected_service_label}, socket) do + selected_dated_service = + socket.assigns.service_groups + |> Enum.flat_map(& &1.services) + |> Enum.find(&(&1.label == selected_service_label)) + + daily_schedule_date = + selected_dated_service.last_service_date + + {:noreply, + socket + |> assign(:selected_service_name, selected_service_label) + |> assign(:daily_schedule_date, daily_schedule_date) + |> assign_departures()} + end + def handle_event(_, _, socket), do: {:noreply, socket} @impl Phoenix.LiveView @@ -206,8 +251,6 @@ defmodule DotcomWeb.ScheduleFinderLive do socket |> assign(:upcoming_departures, []) end - defp today, do: service_date() |> format!(:iso_date) - defp assign_alerts(%{assigns: %{stop: stop}} = socket) when not is_nil(stop) do route = socket.assigns.route assign(socket, :alerts, current_alerts(stop, route)) @@ -218,7 +261,7 @@ defmodule DotcomWeb.ScheduleFinderLive do defp assign_departures(socket) do route_id = socket.assigns.route.id direction_id = socket.assigns.direction_id - date = socket.assigns.date + date = socket.assigns.daily_schedule_date stop = socket.assigns.stop if stop do @@ -295,6 +338,45 @@ defmodule DotcomWeb.ScheduleFinderLive do """ end + attr :service_groups, :list, required: true + attr :selected_service_name, :string, default: "" + + defp service_picker(assigns) do + ~H""" +
0} + phx-change="select_service" + class="mb-lg" + id="service-picker-form" + > + + + + {@selected_service_name} + +
+ """ + end + attr :stop, Stop defp stop_banner(assigns) do @@ -319,7 +401,7 @@ defmodule DotcomWeb.ScheduleFinderLive do attr :times, :list, required: true attr :vehicle_name, :string, required: true - defp first_last(%{times: [first | _] = times} = assigns) do + defp first_last(%{times: [first, _second | _] = times} = assigns) do assigns = assigns |> assign(:first, first) @@ -542,7 +624,7 @@ defmodule DotcomWeb.ScheduleFinderLive do defp other_stop(assigns) do ~H""" - <.lined_list_item route={@route} class={@class}> + <.lined_list_item :if={@other_stop} route={@route} class={@class}>
{@other_stop.stop_name}
@@ -661,4 +743,21 @@ defmodule DotcomWeb.ScheduleFinderLive do |> Kernel.then(& &1.time) |> format!(:hour_12_minutes) end + + defp no_service_message(service_groups, route, stop) do + route_name = + if(route.type == 3 && not Route.silver_line?(route), + do: gettext("Route %{route}", route: route.name), + else: route.name + ) + + if service_groups == [] do + gettext("There is currently no scheduled %{route_name}.", route_name: route_name) + else + gettext("There is no scheduled %{route} service at %{stop} for this time period.", + route: route_name, + stop: stop.name + ) + end + end end diff --git a/test/dotcom/schedule_finder/service_groups_test.exs b/test/dotcom/schedule_finder/service_groups_test.exs new file mode 100644 index 0000000000..51acee8de9 --- /dev/null +++ b/test/dotcom/schedule_finder/service_groups_test.exs @@ -0,0 +1,71 @@ +defmodule Dotcom.ScheduleFinder.ServiceGroupTest do + use ExUnit.Case + + import Dotcom.ScheduleFinder.ServiceGroup + import Dotcom.Utils.ServiceDateTime, only: [service_date: 0] + import Mox + import Test.Support.Factories.Services.Service + + alias Dotcom.ScheduleFinder.ServiceGroup + alias Test.Support.{FactoryHelpers, Generators} + + setup :verify_on_exit! + + setup do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + :ok + end + + describe "for_route/2" do + test "requests services for route" do + date = Generators.DateTime.random_date_time() |> DateTime.to_date() + route_id = FactoryHelpers.build(:id) + + expect(Services.Repo.Mock, :by_route_id, fn ^route_id -> + [] + end) + + assert for_route(route_id, date) + end + + test "marks active routes based on input date" do + services = build_list(1, :service, date: service_date()) + date = List.first(services) |> Map.get(:start_date) + route_id = FactoryHelpers.build(:id) + + expect(Services.Repo.Mock, :by_route_id, fn ^route_id -> + services + end) + + assert [%ServiceGroup{services: [active_service]}] = for_route(route_id, date) + assert active_service.now_date + end + + test "if no active routes, mark next active route" do + inactive_date = Faker.Date.backward(1) |> Date.shift(day: -120) + route_id = FactoryHelpers.build(:id) + + expect(Services.Repo.Mock, :by_route_id, fn ^route_id -> + build_list(50, :service) + end) + + # not every groups will necessarily have a service which is now/next since that's calculated across all groups. so check all of them + all_services = for_route(route_id, inactive_date) |> Enum.flat_map(& &1.services) + assert Enum.all?(all_services, &(&1.now_date == nil)) + assert Enum.any?(all_services, &(&1.next_date != nil)) + end + + test "groups and labels every kind of service" do + date = Generators.DateTime.random_date_time() |> DateTime.to_date() + route_id = FactoryHelpers.build(:id) + + expect(Services.Repo.Mock, :by_route_id, fn ^route_id -> + build_list(50, :service) + end) + + for %ServiceGroup{group_label: label} <- for_route(route_id, date) do + assert is_binary(label) + end + end + end +end diff --git a/test/dotcom/schedule_finder_test.exs b/test/dotcom/schedule_finder_test.exs index 4fbcd78a99..285b07fae9 100644 --- a/test/dotcom/schedule_finder_test.exs +++ b/test/dotcom/schedule_finder_test.exs @@ -134,10 +134,11 @@ defmodule Dotcom.ScheduleFinderTest do test "returns route, destination, departure times" do direction_id = Faker.Util.pick([0, 1]) stop_id = FactoryHelpers.build(:id) + route = Test.Support.Factories.Routes.Route.build(:route) departures = departures() - expect(Routes.Repo.Mock, :get, length(departures), fn id -> - Test.Support.Factories.Routes.Route.build(:route, %{id: id}) + expect(Routes.Repo.Mock, :get, length(departures), fn _ -> + route end) assert [{route, destination, times} | _] = subway_groups(departures, direction_id, stop_id) @@ -152,9 +153,10 @@ defmodule Dotcom.ScheduleFinderTest do ~w(place-nqncy place-wlsta place-qnctr place-qamnl place-brntn) |> Faker.Util.pick() departures = departures() + route = Test.Support.Factories.Routes.Route.build(:route, %{id: "Red"}) expect(Routes.Repo.Mock, :get, length(departures), fn _ -> - Test.Support.Factories.Routes.Route.build(:route, %{id: "Red"}) + route end) assert [{route, destination, _} | _] = diff --git a/test/dotcom/service_patterns_test.exs b/test/dotcom/service_patterns_test.exs index b56eb5d6c8..733086de92 100644 --- a/test/dotcom/service_patterns_test.exs +++ b/test/dotcom/service_patterns_test.exs @@ -2,6 +2,7 @@ defmodule Dotcom.ServicePatternsTest do use ExUnit.Case, async: true import Dotcom.ServicePatterns + import Dotcom.Utils.ServiceDateTime, only: [service_date: 0] import Mox import Test.Support.Factories.Services.Service @@ -28,7 +29,7 @@ defmodule Dotcom.ServicePatternsTest do test "returns true if there are services for that date" do expect(Services.Repo.Mock, :by_route_id, fn _ -> - build_list(5, :service, %{date: Dotcom.Utils.ServiceDateTime.service_date()}) + build_list(5, :service, %{date: service_date()}) end) assert has_service?(route: FactoryHelpers.build(:id)) @@ -44,15 +45,168 @@ defmodule Dotcom.ServicePatternsTest do test "returns false if services only serve other dates" do expect(Services.Repo.Mock, :by_route_id, fn _ -> - build_list(5, :service, %{date: Dotcom.Utils.ServiceDateTime.service_date()}) + build_list(5, :service, %{date: service_date()}) end) other_date = - Dotcom.Utils.DateTime.now() - |> DateTime.to_date() + service_date() |> Date.shift(year: 5) refute has_service?(route: FactoryHelpers.build(:id), date: other_date) end end + + describe "for_route/1" do + test "omits canonical typicality" do + route_id = FactoryHelpers.build(:id) + + expect(Services.Repo.Mock, :by_route_id, fn ^route_id -> + [build(:service, date: service_date(), typicality: :canonical)] + end) + + assert for_route(route_id) == [] + end + + test "renames (no school) typical services" do + route_id = FactoryHelpers.build(:id) + + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [ + build(:service, + date: service_date(), + description: "Weekdays (no school)", + typicality: :typical_service, + type: :weekday + ) + ] + end) + + assert [%{service_label: {:typical, :weekday, "Weekday schedules"}}] = for_route(route_id) + end + + test "omits services which ended" do + route_id = FactoryHelpers.build(:id) + + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [build(:service, end_date: Faker.Date.backward(1))] + end) + + assert for_route(route_id) == [] + end + + test "splits multi-holiday services" do + route_id = FactoryHelpers.build(:id) + holiday_count = Faker.random_between(2, 4) + service = build(:service, date: Faker.Date.forward(1), typicality: :holiday_service) + + added_dates = + Faker.Util.sample_uniq(holiday_count, fn -> + shift = Faker.random_between(2, 6) + + Date.shift(service.end_date, day: shift) + |> Date.to_string() + end) + + added_dates_notes = + Map.new(added_dates, fn d -> + {d, Faker.Commerce.product_name()} + end) + + service = %{service | added_dates: added_dates, added_dates_notes: added_dates_notes} + + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [service] + end) + + patterns = for_route(route_id) + assert Enum.count(patterns) == holiday_count + assert Enum.all?(patterns, &(&1.group_label == {:holiday, "Holiday Schedules"})) + end + + test "adjusts description to add formatted date, for single date services" do + route_id = FactoryHelpers.build(:id) + added_date = Faker.random_between(2, 10) |> Faker.Date.forward() |> Date.to_string() + holiday_description = Faker.Commerce.product_name() + added_date_notes = Map.new([{added_date, holiday_description}]) + + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [ + build(:service, + date: service_date(), + typicality: :holiday_service, + added_dates: [added_date], + added_dates_notes: added_date_notes + ) + ] + end) + + assert patterns = for_route(route_id) + %{service_label: {_, _, text}} = List.first(patterns) + assert text =~ holiday_description + + assert text =~ + added_date |> Date.from_iso8601!() |> Dotcom.Utils.Time.format!(:month_day_short) + end + + test "adjusts description for planned work" do + route_id = FactoryHelpers.build(:id) + disruption_service = build(:service, date: service_date(), typicality: :planned_disruption) + + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [disruption_service] + end) + + assert [%{service_label: {:planned_disruption, _, label}}] = for_route(route_id) + assert disruption_service.description != label + assert label =~ disruption_service.description + end + + test "omits duplicates" do + route_id = FactoryHelpers.build(:id) + service = build(:service, date: service_date()) + + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [service, service] + end) + + assert for_route(route_id) |> Enum.count() == 1 + end + + test "omits weekday service if similar" do + route_id = FactoryHelpers.build(:id) + + mon_thurs_service = + build(:service, + date: service_date(), + typicality: :typical_service, + type: :weekday, + valid_days: [1, 2, 3, 4] + ) + + friday_service = mon_thurs_service |> Map.put(:valid_days, [5]) + spurious_weekday_service = mon_thurs_service |> Map.put(:valid_days, [1, 2, 3, 4, 5]) + + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [friday_service, mon_thurs_service, spurious_weekday_service] + end) + + assert spurious_weekday_service not in for_route(route_id) + end + + test "omits overlapping service" do + route_id = FactoryHelpers.build(:id) + service = build(:service, date: service_date(), typicality: :typical_service) + + overlapping_service = + service + |> Map.update!(:start_date, &Date.shift(&1, day: -1)) + |> Map.update!(:end_date, &Date.shift(&1, day: 1)) + + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [overlapping_service, service] + end) + + assert for_route(route_id) |> Enum.count() == 1 + end + end end diff --git a/test/dotcom_web/controllers/schedule/line_controller_test.exs b/test/dotcom_web/controllers/schedule/line_controller_test.exs index 88eaf42a68..b3ecdadf52 100644 --- a/test/dotcom_web/controllers/schedule/line_controller_test.exs +++ b/test/dotcom_web/controllers/schedule/line_controller_test.exs @@ -1,13 +1,21 @@ defmodule DotcomWeb.Schedule.LineControllerTest do use DotcomWeb.ConnCase, async: false - import Mock - alias DotcomWeb.ScheduleController.LineController - alias Services.Service + + import Mox + import Test.Support.Factories.Services.Service + + setup :verify_on_exit! @moduletag :external + setup do + stub_with(Dotcom.Utils.DateTime.Mock, Dotcom.Utils.DateTime) + + :ok + end + setup_all do # needed by DotcomWeb.ScheduleController.VehicleLocations plug _ = start_supervised({Phoenix.PubSub, name: Vehicles.PubSub}) @@ -31,140 +39,17 @@ defmodule DotcomWeb.Schedule.LineControllerTest do end end - describe "services/3" do + describe "services/2" do test "omits services in the past" do - service_date = ~D[2019-05-01] - - past_service = %Service{ - start_date: ~D[2019-04-30], - end_date: ~D[2019-04-30] - } - - current_service = %Service{ - start_date: ~D[2019-05-01], - end_date: ~D[2019-05-01] - } - - future_service = %Service{ - start_date: ~D[2019-05-02], - end_date: ~D[2019-05-02] - } - - repo_fn = fn _ -> - [ - past_service, - current_service, - future_service - ] - end - - services = LineController.services("1", service_date, repo_fn) - assert length(services) == 2 - refute Enum.member?(services, past_service) - end - - @tag skip: "Stopped omitting these, might restore one day" - test "omits no-school weekday services" do - service_date = ~D[2019-12-11] - - school_service = %Service{ - start_date: ~D[2019-12-11], - end_date: ~D[2019-12-11], - name: "Weekday" - } - - no_school_service = %Service{ - start_date: ~D[2019-12-12], - end_date: ~D[2019-12-12], - name: "Weekday (no school)" - } - - repo_fn = fn _ -> - [ - school_service, - no_school_service - ] - end - - services = LineController.services("1", service_date, repo_fn) - assert length(services) == 1 - refute Enum.member?(services, no_school_service) - end - - test "omits services that are subsets of another service" do - service_date = ~D[2019-05-01] - - subset_service = %Service{ - start_date: ~D[2019-05-30], - end_date: ~D[2019-06-30] - } - - superset_service = %Service{ - start_date: ~D[2019-04-29], - end_date: ~D[2019-07-01] - } - - unrelated_service = %Service{ - start_date: ~D[2019-07-02], - end_date: ~D[2019-09-02] - } - - repo_fn = fn _ -> - [ - subset_service, - superset_service, - unrelated_service - ] - end - - services = LineController.services("1", service_date, repo_fn) - assert length(services) == 2 - refute Enum.member?(services, subset_service) - end - - test "does not break even when there's an error getting the current rating" do - with_mock(Schedules.Repo, [:passthrough], end_of_rating: fn -> nil end) do - service_date = ~D[2021-05-01] - - repo_fn = fn _ -> - [ - %Service{ - start_date: ~D[2021-05-01], - end_date: ~D[2021-05-01] - } - ] - end - - services = LineController.services("1", service_date, repo_fn) - - assert length(services) == 1 - end - end - - test "does not include canonical services" do - service_date = ~D[2019-05-01] - - current_service = %Service{ - start_date: ~D[2019-05-01], - end_date: ~D[2019-05-01] - } - - canonical_service = %Service{ - typicality: :canonical, - start_date: ~D[2019-05-02], - end_date: ~D[2019-05-02] - } + past_service = + build(:service, end_date: Faker.Date.backward(3), typicality: :typical_service) - repo_fn = fn _ -> - [ - current_service, - canonical_service - ] - end + expect(Services.Repo.Mock, :by_route_id, fn _ -> + [past_service] + end) - services = LineController.services("1", service_date, repo_fn) - assert length(services) == 1 - refute Enum.member?(services, canonical_service) + services = LineController.services("1", Faker.Date.forward(3)) + assert Enum.empty?(services) end end diff --git a/test/services/service_test.exs b/test/services/service_test.exs index d9b6b8aa35..ca304f7527 100644 --- a/test/services/service_test.exs +++ b/test/services/service_test.exs @@ -197,13 +197,13 @@ defmodule Services.ServiceTest do end test "accounts for nil start date" do - service = build(:service, start_date: nil) + service = build(:service, date: Faker.Date.forward(1), start_date: nil) assert dates = Service.all_valid_dates_for_service(service) refute Enum.empty?(dates) end test "accounts for nil end date" do - service = build(:service, end_date: nil) + service = build(:service, date: Faker.Date.backward(1), end_date: nil) assert dates = Service.all_valid_dates_for_service(service) refute Enum.empty?(dates) end