From 6977f8d25090dd0564003bd715ba8d5d7d8b6032 Mon Sep 17 00:00:00 2001 From: JD Date: Mon, 20 Jul 2026 15:52:08 -0400 Subject: [PATCH] Revert "Remove alerts hook (#1045)" This reverts commit 1cb8ccc46ff6067105d9f58e14c455dd8a8bdeae. --- apps/state/lib/state/alert.ex | 3 + apps/state/lib/state/alert/hooks.ex | 117 +++++ apps/state/test/state/alert/hooks_test.exs | 552 +++++++++++++++++++++ apps/state/test/state/alert_test.exs | 207 +++++++- 4 files changed, 871 insertions(+), 8 deletions(-) create mode 100644 apps/state/lib/state/alert/hooks.ex create mode 100644 apps/state/test/state/alert/hooks_test.exs diff --git a/apps/state/lib/state/alert.ex b/apps/state/lib/state/alert.ex index c3c9c6a46..af6d925bd 100644 --- a/apps/state/lib/state/alert.ex +++ b/apps/state/lib/state/alert.ex @@ -77,4 +77,7 @@ defmodule State.Alert do for table <- @subtables, do: table.update(all_alerts) :ok end + + @impl State.Server + defdelegate pre_insert_hook(alert), to: State.Alert.Hooks end diff --git a/apps/state/lib/state/alert/hooks.ex b/apps/state/lib/state/alert/hooks.ex new file mode 100644 index 000000000..a4ca3edea --- /dev/null +++ b/apps/state/lib/state/alert/hooks.ex @@ -0,0 +1,117 @@ +defmodule State.Alert.Hooks do + @moduledoc """ + Implementation of hooks to process Alerts before inserting them into the table. + """ + alias Model.Alert + + @spec pre_insert_hook(Alert.t()) :: [Alert.t()] + def pre_insert_hook(alert) do + entities = add_computed_entities(alert.informed_entity) + + [ + %{alert | informed_entity: entities} + ] + end + + defp add_computed_entities(entities) do + entities + |> Stream.concat(get_parent_station_entities(entities)) + |> Enum.flat_map(&include_alternate_trip_entities/1) + |> Enum.uniq() + end + + # Parent station informed entities that share values for these keys + # will each have their `activities` replaced with the union of all of + # the group's `activities` lists: + # [%{activities: ["BOARD"]}, %{activities: ["BOARD", "EXIT"]}] + # => + # [%{activities: ["BOARD", "EXIT"]}, %{activities: ["BOARD", "EXIT"]}] + @parent_station_entity_activity_merge_criteria [:route, :stop, :trip] + + @spec get_parent_station_entities([Alert.informed_entity()]) :: [Alert.informed_entity()] + defp get_parent_station_entities(entities) do + entities + |> Stream.map(&get_parent_station_entity/1) + |> Stream.reject(&is_nil/1) + |> Enum.group_by(&Map.take(&1, @parent_station_entity_activity_merge_criteria)) + |> Enum.flat_map(&merge_parent_entity_activities/1) + end + + defp get_parent_station_entity(%{stop: stop_id} = entity) when is_binary(stop_id) do + case State.Stop.by_id(stop_id) do + %{parent_station: station} when is_binary(station) -> + %{entity | stop: station} + + _ -> + nil + end + end + + defp get_parent_station_entity(_entity) do + nil + end + + defp merge_parent_entity_activities({_key, parent_entities}) do + merged_activities = + parent_entities + |> Enum.flat_map(&(&1[:activities] || [])) + |> Enum.uniq() + |> Enum.sort() + + if merged_activities == [] do + parent_entities + else + parent_entities + |> Enum.map(&Map.put(&1, :activities, merged_activities)) + |> Enum.uniq() + end + end + + @spec include_alternate_trip_entities(Alert.informed_entity()) :: [Alert.informed_entity()] + defp include_alternate_trip_entities(%{trip: trip_id} = entity) when is_binary(trip_id) do + case all_route_entities(entity) do + [] -> + [entity] + + entities -> + entities + end + end + + defp include_alternate_trip_entities(entity) do + [entity] + end + + defp all_route_entities(%{trip: trip_id} = entity) when is_binary(trip_id) do + trips = State.Trip.by_id(trip_id) + + trip_entities = + for trip <- trips do + merge = + case State.Route.by_id(trip.route_id) do + nil -> + %{route: trip.route_id, direction_id: trip.direction_id} + + route -> + %{ + route: route.id, + route_type: route.type, + direction_id: trip.direction_id + } + end + + Map.merge(entity, merge) + end + + with {:ok, route_id} <- Map.fetch(entity, :route), + false <- Enum.any?(trips, &(&1.route_id == route_id)) do + # if the route in the alert doesn't match the routes we have for the + # trip, keep the original entity + [entity | trip_entities] + else + _ -> + # otherwise, ignore the original entity + trip_entities + end + end +end diff --git a/apps/state/test/state/alert/hooks_test.exs b/apps/state/test/state/alert/hooks_test.exs new file mode 100644 index 000000000..cdb3544ac --- /dev/null +++ b/apps/state/test/state/alert/hooks_test.exs @@ -0,0 +1,552 @@ +defmodule State.Alert.HooksTest do + @moduledoc false + use ExUnit.Case + import State.Alert.Hooks + alias Model.Alert + + setup do + State.Stop.new_state([]) + State.Trip.new_state([]) + State.Route.new_state([]) + :ok + end + + @type hook_result :: %{ + added: [Alert.informed_entity()], + removed: [Alert.informed_entity()], + preserved: [Alert.informed_entity()] + } + + @spec apply_hook([Alert.informed_entity()]) :: hook_result + @spec apply_hook(Alert.t()) :: hook_result + defp apply_hook(informed_entities) when is_list(informed_entities) do + apply_hook(%Alert{id: "alert1", informed_entity: informed_entities}) + end + + defp apply_hook(%Alert{} = alert) do + assert [%Alert{informed_entity: new_informed_entities}] = pre_insert_hook(alert) + + old = MapSet.new(alert.informed_entity) + new = MapSet.new(new_informed_entities) + + [ + added: MapSet.difference(new, old), + removed: MapSet.difference(old, new), + preserved: MapSet.intersection(old, new) + ] + |> Map.new(fn {k, ies} -> {k, normalize(ies)} end) + end + + defp normalize(ies) do + ies + |> Enum.map(fn ie -> Map.replace_lazy(ie, :activities, &Enum.sort/1) end) + |> Enum.sort() + end + + describe "pre_insert_hook/1" do + test "adds informed entities for parent stations" do + State.Stop.new_state([ + %Model.Stop{id: "child-stop1", parent_station: "parent-stationA"}, + %Model.Stop{id: "child-stop2", parent_station: "parent-stationB"}, + %Model.Stop{id: "child-stop3", parent_station: "parent-stationB"}, + %Model.Stop{id: "parentless-stop"} + ]) + + informed_entities = + [ + %{stop: "child-stop1"}, + %{stop: "child-stop2"}, + %{stop: "child-stop3"}, + %{stop: "parentless-stop"} + ] + |> normalize() + + assert %{ + preserved: ^informed_entities, + added: [%{stop: "parent-stationA"}, %{stop: "parent-stationB"}] + } = apply_hook(informed_entities) + end + + test "merges child informed entities' activities for parent station informed entities," <> + " and does *not* merge activities for pre-existing child stop informed entities" do + State.Stop.new_state([ + %Model.Stop{id: "child-stop1", parent_station: "parent-stationA"}, + %Model.Stop{id: "child-stop2", parent_station: "parent-stationA"}, + %Model.Stop{id: "child-stop3", parent_station: "parent-stationB"}, + %Model.Stop{id: "child-stop4", parent_station: "parent-stationB"}, + %Model.Stop{id: "child-stop5", parent_station: "parent-stationC"}, + %Model.Stop{id: "parentless-stop"} + ]) + + informed_entities = + [ + %{ + stop: "child-stop1", + route: "route1", + activities: ["BOARD", "EXIT", "USING_WHEELCHAIR"] + }, + %{stop: "child-stop1", route: "route2", activities: ["BOARD"]}, + %{stop: "child-stop2", route: "route1", activities: ["BOARD", "EXIT", "RIDE"]}, + %{stop: "child-stop3", activities: ["BOARD"], route: "route1", trip: "trip1"}, + %{stop: "child-stop4", activities: ["EXIT"], route: "route1", trip: "trip1"}, + %{stop: "child-stop4", activities: ["RIDE"], route: "route1", trip: "trip2"}, + %{stop: "child-stop4", activities: ["RIDE"], route: "route2", trip: "trip1"}, + %{stop: "child-stop5", activities: ["USING_ESCALATOR"]}, + %{stop: "parentless-stop", activities: ["BRINGING_BIKE", "STORE_BIKE"]} + ] + |> normalize() + + assert %{ + preserved: ^informed_entities, + added: [ + %{stop: "parent-stationC", activities: ["USING_ESCALATOR"]}, + %{stop: "parent-stationA", route: "route2", activities: ["BOARD"]}, + %{ + stop: "parent-stationA", + route: "route1", + activities: ["BOARD", "EXIT", "RIDE", "USING_WHEELCHAIR"] + }, + %{ + stop: "parent-stationB", + activities: ["BOARD", "EXIT"], + route: "route1", + trip: "trip1" + }, + %{stop: "parent-stationB", activities: ["RIDE"], route: "route1", trip: "trip2"}, + %{stop: "parent-stationB", activities: ["RIDE"], route: "route2", trip: "trip1"} + ] + } = apply_hook(informed_entities) + end + + test "adds informed entities for alternate trips" do + State.Trip.new_state([ + %Model.Trip{id: "trip1", alternate_route: false, route_id: "main-route"}, + %Model.Trip{id: "trip1", alternate_route: true, route_id: "alt-route1"}, + %Model.Trip{id: "trip1", alternate_route: true, route_id: "alt-route2"}, + %Model.Trip{id: "trip1", alternate_route: true, route_id: "alt-routeX", direction_id: 1} + ]) + + State.Route.new_state([ + %Model.Route{id: "main-route", type: 3}, + %Model.Route{id: "alt-route1", type: 3}, + %Model.Route{id: "alt-route2", type: 3} + ]) + + informed_entities = + [ + %{ + stop: "bus-stop1", + trip: "trip1", + route: "main-route", + direction_id: 0, + activities: ["BOARD", "EXIT"] + }, + %{ + stop: "bus-stop2", + trip: "trip1", + route: "a-different-route", + direction_id: 0, + activities: ["BOARD"] + }, + %{stop: "bus-stop3", trip: "trip2", direction_id: 1} + ] + |> normalize() + + assert %{ + preserved: [ + %{stop: "bus-stop3", trip: "trip2", direction_id: 1}, + %{ + stop: "bus-stop2", + trip: "trip1", + route: "a-different-route", + direction_id: 0, + activities: ["BOARD"] + } + ], + added: [ + %{ + stop: "bus-stop2", + trip: "trip1", + route: "alt-routeX", + direction_id: 1, + activities: ["BOARD"] + }, + %{ + stop: "bus-stop1", + trip: "trip1", + route: "alt-routeX", + direction_id: 1, + activities: ["BOARD", "EXIT"] + }, + %{ + stop: "bus-stop2", + route: "alt-route1", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD"] + }, + %{ + stop: "bus-stop2", + route: "alt-route2", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD"] + }, + %{ + stop: "bus-stop2", + route: "main-route", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD"] + }, + %{ + stop: "bus-stop1", + route: "alt-route1", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "EXIT"] + }, + %{ + stop: "bus-stop1", + route: "alt-route2", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "EXIT"] + }, + %{ + stop: "bus-stop1", + route: "main-route", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "EXIT"] + } + ], + removed: [ + %{ + stop: "bus-stop1", + trip: "trip1", + route: "main-route", + direction_id: 0, + activities: ["BOARD", "EXIT"] + } + ] + } = apply_hook(informed_entities) + end + + test "adds Cartesian product of computed informed entities for parent stations X alternate trips" do + State.Stop.new_state([ + %Model.Stop{id: "busway-berth1", parent_station: "parent-stationA"}, + %Model.Stop{id: "busway-berth2", parent_station: "parent-stationA"} + ]) + + State.Trip.new_state([ + %Model.Trip{id: "trip1", alternate_route: false, route_id: "main-route"}, + %Model.Trip{id: "trip1", alternate_route: true, route_id: "alt-route"} + ]) + + State.Route.new_state([ + %Model.Route{id: "main-route", type: 3}, + %Model.Route{id: "alt-route", type: 3} + ]) + + informed_entities = + [ + %{ + stop: "busway-berth1", + trip: "trip1", + route: "main-route", + direction_id: 0, + activities: ["BOARD", "EXIT"] + }, + %{ + stop: "busway-berth2", + trip: "trip1", + route: "main-route", + direction_id: 0, + activities: ["BOARD", "USING_WHEELCHAIR"] + } + ] + |> normalize() + + assert %{ + preserved: [], + added: [ + %{ + stop: "busway-berth1", + route: "alt-route", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "EXIT"] + }, + %{ + stop: "busway-berth1", + route: "main-route", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "EXIT"] + }, + %{ + stop: "parent-stationA", + route: "alt-route", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "EXIT", "USING_WHEELCHAIR"] + }, + %{ + stop: "parent-stationA", + route: "main-route", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "EXIT", "USING_WHEELCHAIR"] + }, + %{ + stop: "busway-berth2", + route: "alt-route", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "USING_WHEELCHAIR"] + }, + %{ + stop: "busway-berth2", + route: "main-route", + direction_id: nil, + route_type: 3, + trip: "trip1", + activities: ["BOARD", "USING_WHEELCHAIR"] + } + ], + removed: [ + %{ + stop: "busway-berth1", + route: "main-route", + direction_id: 0, + trip: "trip1", + activities: ["BOARD", "EXIT"] + }, + %{ + stop: "busway-berth2", + route: "main-route", + direction_id: 0, + trip: "trip1", + activities: ["BOARD", "USING_WHEELCHAIR"] + } + ] + } = apply_hook(informed_entities) + end + + test "handles specific case from bugfix ticket" do + # https://app.asana.com/1/15492006741476/project/584764604969369/task/1213450825693783?focus=true + + State.Stop.new_state([ + %Model.Stop{id: "BNT-0000", parent_station: "place-north"}, + %Model.Stop{id: "WR-0045-S", parent_station: "place-mlmnl"}, + %Model.Stop{id: "WR-0053-S", parent_station: "place-ogmnl"} + ]) + + assert [alert] = Parse.Alerts.parse(alerts_enhanced_json_excerpt()) + + informed_entities = normalize(alert.informed_entity) + + expected_new_informed_entities = + [ + %{ + stop: "place-mlmnl", + activities: ["BOARD", "EXIT", "RIDE"], + route: "CR-Haverhill", + direction_id: 1, + route_type: 2 + }, + %{ + stop: "place-mlmnl", + activities: ["BOARD", "EXIT", "RIDE"], + route: "CR-Haverhill", + direction_id: 0, + route_type: 2 + }, + %{ + stop: "place-north", + activities: ["BOARD", "EXIT", "RIDE"], + route: "CR-Haverhill", + direction_id: 1, + route_type: 2 + }, + %{ + stop: "place-north", + activities: ["BOARD", "EXIT", "RIDE"], + route: "CR-Haverhill", + direction_id: 0, + route_type: 2 + }, + %{ + stop: "place-ogmnl", + activities: ["BOARD", "EXIT", "RIDE"], + route: "CR-Haverhill", + direction_id: 1, + route_type: 2 + }, + %{ + stop: "place-ogmnl", + activities: ["BOARD", "EXIT", "RIDE"], + route: "CR-Haverhill", + direction_id: 0, + route_type: 2 + } + ] + |> normalize() + + assert %{ + preserved: ^informed_entities, + added: ^expected_new_informed_entities + } = apply_hook(alert) + end + end + + defp alerts_enhanced_json_excerpt do + ~S""" + { + "entity": [ + { + "id": "1000217", + "alert": { + "cause": "UNKNOWN_CAUSE", + "effect": "REDUCED_SERVICE", + "severity": 7, + "active_period": [ + { + "start": 1772415300, + "end": 1772697600 + } + ], + "duration_certainty": "KNOWN", + "cause_detail": { + "translation": [ + { + "text": "UNKNOWN_CAUSE", + "language": "en" + } + ] + }, + "effect_detail": { + "translation": [ + { + "text": "SUSPENSION", + "language": "en" + } + ] + }, + "informed_entity": [ + { + "direction_id": 1, + "stop_id": "BNT-0000", + "route_id": "CR-Haverhill", + "route_type": 2, + "agency_id": "1", + "activities": [ + "EXIT", + "RIDE" + ] + }, + { + "direction_id": 1, + "stop_id": "WR-0045-S", + "route_id": "CR-Haverhill", + "route_type": 2, + "agency_id": "1", + "activities": [ + "BOARD", + "EXIT", + "RIDE" + ] + }, + { + "direction_id": 1, + "stop_id": "WR-0053-S", + "route_id": "CR-Haverhill", + "route_type": 2, + "agency_id": "1", + "activities": [ + "BOARD", + "RIDE" + ] + }, + { + "direction_id": 0, + "stop_id": "BNT-0000", + "route_id": "CR-Haverhill", + "route_type": 2, + "agency_id": "1", + "activities": [ + "BOARD", + "RIDE" + ] + }, + { + "direction_id": 0, + "stop_id": "WR-0045-S", + "route_id": "CR-Haverhill", + "route_type": 2, + "agency_id": "1", + "activities": [ + "BOARD", + "EXIT", + "RIDE" + ] + }, + { + "direction_id": 0, + "stop_id": "WR-0053-S", + "route_id": "CR-Haverhill", + "route_type": 2, + "agency_id": "1", + "activities": [ + "EXIT", + "RIDE" + ] + } + ], + "last_modified_timestamp": 1772415513, + "severity_level": "SEVERE", + "header_text": { + "translation": [ + { + "text": "Haverhill Line: service suspended between North Station and Oak Grove today.", + "language": "en" + } + ] + }, + "description_text": { + "translation": [ + { + "text": "Affected stations:\r\nNorth Station\r\nMalden Center\r\nOak Grove", + "language": "en" + } + ] + }, + "service_effect_text": { + "translation": [ + { + "text": "Suspension of service on Haverhill Line", + "language": "en" + } + ] + }, + "created_timestamp": 1772415356, + "alert_lifecycle": "NEW" + } + } + ] + } + """ + end +end diff --git a/apps/state/test/state/alert_test.exs b/apps/state/test/state/alert_test.exs index 1ac91b824..4bb2f3842 100644 --- a/apps/state/test/state/alert_test.exs +++ b/apps/state/test/state/alert_test.exs @@ -3,6 +3,7 @@ defmodule State.AlertTest do alias Parse.Time alias State.Alert.{InformedEntity, InformedEntityActivity} + alias State.{Service, Trip} import State.Alert @@ -19,7 +20,12 @@ defmodule State.AlertTest do service_id: @service_id } @today Time.service_date() - + @service %Model.Service{ + id: @service_id, + start_date: @today, + end_date: @today, + added_dates: [@today] + } @alert %Model.Alert{ id: @alert_id, informed_entity: [ @@ -172,13 +178,7 @@ defmodule State.AlertTest do alert = put_in(@alert.informed_entity, [ - %{ - activities: ["BOARD"], - trip: trip, - route: route.id, - route_type: route.type, - direction_id: 1 - } + %{activities: ["BOARD"], trip: "trip"} ]) insert_alerts!([alert]) @@ -407,4 +407,195 @@ defmodule State.AlertTest do assert_receive :done, 200 end end + + describe "alternate trip entities" do + setup :service_by_date + + test "updates entities to include alternate routes" do + added_route_id = "added_route_id" + + Trip.new_state(%{ + multi_route_trips: [ + %Model.MultiRouteTrip{added_route_id: added_route_id, trip_id: @trip_id} + ], + trips: [@trip] + }) + + input_informed_entity = %{direction_id: 1, route: @route_id, route_type: 3, trip: @trip_id} + + new_state([%Model.Alert{id: @alert_id, informed_entity: [input_informed_entity]}]) + + %Model.Alert{informed_entity: output_informed_entity} = by_id(@alert_id) + + assert length(output_informed_entity) == 2 + + assert %{direction_id: 1, route: @route_id, route_type: 3, trip: @trip_id} in output_informed_entity + + assert %{direction_id: 1, route: added_route_id, route_type: 3, trip: @trip_id} in output_informed_entity + end + + test "does not set `route` if no alternate routes" do + Trip.new_state(%{multi_route_trips: [], trips: [@trip]}) + informed_entity = [%{direction_id: 1, route: @route_id, route_type: 3, trip: @trip_id}] + new_state([%Model.Alert{id: @alert_id, informed_entity: informed_entity}]) + + alert = by_id(@alert_id) + + assert alert.informed_entity == informed_entity + end + + test "returns original entity if `trip` is not found" do + informed_entity = [%{trip: "unknown"}] + new_state([%Model.Alert{id: @alert_id, informed_entity: informed_entity}]) + alert = by_id(@alert_id) + + assert alert.informed_entity == informed_entity + end + + test "includes original route if `trip` does not have route" do + added_route_id = "added_route_id" + + Trip.new_state(%{ + multi_route_trips: [ + %Model.MultiRouteTrip{added_route_id: added_route_id, trip_id: @trip_id} + ], + trips: [@trip] + }) + + other_route_id = "other_route_id" + + input_informed_entity = %{ + direction_id: 1, + route: other_route_id, + route_type: 3, + trip: @trip_id + } + + new_state([%Model.Alert{id: @alert_id, informed_entity: [input_informed_entity]}]) + + %Model.Alert{informed_entity: output_informed_entity} = by_id(@alert_id) + + assert length(output_informed_entity) == 3 + + assert %{direction_id: 1, route: @route_id, route_type: 3, trip: @trip_id} in output_informed_entity + + assert %{direction_id: 1, route: other_route_id, route_type: 3, trip: @trip_id} in output_informed_entity + + assert %{direction_id: 1, route: added_route_id, route_type: 3, trip: @trip_id} in output_informed_entity + end + + test "only includes alternate routes once if incoming informed entities already contain alternate route" do + added_by_upstream_route_id = "added_by_upstream_route_id" + added_by_multi_route_trips_route_id = "added_by_multi_route_trips_route_id" + + Trip.new_state(%{ + multi_route_trips: [ + %Model.MultiRouteTrip{added_route_id: added_by_upstream_route_id, trip_id: @trip_id}, + %Model.MultiRouteTrip{ + added_route_id: added_by_multi_route_trips_route_id, + trip_id: @trip_id + } + ], + trips: [@trip] + }) + + primary_upstream_informed_entity = %{ + direction_id: 1, + route: @route_id, + route_type: 3, + trip: @trip_id + } + + alternate_upstream_informed_entity = %{ + primary_upstream_informed_entity + | route: added_by_upstream_route_id + } + + new_state([ + %Model.Alert{ + id: @alert_id, + informed_entity: [primary_upstream_informed_entity, alternate_upstream_informed_entity] + } + ]) + + %Model.Alert{informed_entity: informed_entities} = by_id(@alert_id) + + assert length(informed_entities) == 3 + + assert primary_upstream_informed_entity in informed_entities + assert alternate_upstream_informed_entity in informed_entities + + added_entity = %{ + direction_id: 1, + route: added_by_multi_route_trips_route_id, + route_type: 3, + trip: @trip_id + } + + assert added_entity in informed_entities + end + + test "includes route/route type/direction in the entity if not already present" do + route = %Model.Route{ + id: @route_id, + type: 3 + } + + State.Route.new_state([route]) + State.Trip.new_state([@trip]) + entity = %{trip: @trip_id} + alert = %Model.Alert{id: @alert_id, informed_entity: [entity]} + State.Alert.new_state([alert]) + + alert = by_id(@alert_id) + + expected_entity = %{ + route_type: 3, + route: @route_id, + direction_id: @trip.direction_id, + trip: @trip_id + } + + assert expected_entity in alert.informed_entity + assert length(alert.informed_entity) == 1 + end + end + + describe "parent station entity" do + test "updates entities to include the parent station" do + State.Stop.new_state([ + %Model.Stop{id: "child", parent_station: "parent"}, + %Model.Stop{id: "parent"} + ]) + + new_state([ + %Model.Alert{id: @alert_id, informed_entity: [%{stop: "child"}]} + ]) + + alert = by_id(@alert_id) + assert %{stop: "child"} in alert.informed_entity + assert %{stop: "parent"} in alert.informed_entity + end + + test "does not include parent station entities twice" do + State.Stop.new_state([ + %Model.Stop{id: "child", parent_station: "parent"}, + %Model.Stop{id: "parent"} + ]) + + new_state([ + %Model.Alert{id: @alert_id, informed_entity: [%{stop: "child"}, %{stop: "parent"}]} + ]) + + alert = by_id(@alert_id) + assert [_, _] = alert.informed_entity + end + end + + defp service_by_date(_) do + # Reset received keys + Trip.reset_gather() + + Service.new_state([@service]) + end end