diff --git a/assets/css/pre_fare_v2.scss b/assets/css/pre_fare_v2.scss index e69bd1e5e..5c4a03d7f 100644 --- a/assets/css/pre_fare_v2.scss +++ b/assets/css/pre_fare_v2.scss @@ -40,6 +40,8 @@ @import "v2/pre_fare/flex/paging_indicator"; @import "v2/pre_fare/full_line_map"; @import "v2/pre_fare/reconstructed_alert"; +@import "v2/pre_fare/alert-banner"; +@import "v2/pre_fare/prefare_single_screen_alert"; @import "v2/pre_fare/free_text"; @import "v2/pre_fare/shuttle_bus_info"; @@ -62,6 +64,8 @@ @import "v2/blue_bikes"; +@import "v2/pre_fare/disruption_diagram/disruption_diagram"; + body { margin: 0px; } diff --git a/assets/css/v2/pre_fare/alert-banner.scss b/assets/css/v2/pre_fare/alert-banner.scss new file mode 100644 index 000000000..ad3cacca9 --- /dev/null +++ b/assets/css/v2/pre_fare/alert-banner.scss @@ -0,0 +1,56 @@ +.alert-banner { + font-family: Inter; + width: 100%; + font-size: 60px; + line-height: 72px; + font-weight: 600; + filter: drop-shadow(0px 30px 20px rgba(0, 0, 0, 0.25)); + + display: flex; + flex-wrap: wrap; + + /* Background colors */ + &--blue { + background-color: #b3c5e4; + } + + &--red { + background-color: #f4bfbb; + } + + &--orange { + background-color: #fadcb3; + } + + &--yellow { + background-color: #fff7bf; + } + + &--green { + background-color: #b3dac5; + } +} + +.alert-banner__attention-text { + font-weight: 700; +} + +.alert-banner__route-pill--short { + margin: 0 20px; + height: 96px; +} + +.alert-banner__route-pill--long { + margin: 16.2px 0 0.8px 0; +} + +.alert-banner--large--two-routes { + padding: 31px 65px 42px 65px; +} +.alert-banner--large--one-route { + padding: 86px 74px 102px 74px; +} +.alert-banner--small { + padding: 52px 88px; + align-items: center; +} diff --git a/assets/css/v2/pre_fare/disruption_diagram/disruption_diagram.scss b/assets/css/v2/pre_fare/disruption_diagram/disruption_diagram.scss new file mode 100644 index 000000000..45a1f3475 --- /dev/null +++ b/assets/css/v2/pre_fare/disruption_diagram/disruption_diagram.scss @@ -0,0 +1,124 @@ +.end-slot__arrow { + &--red { + fill: $line-color-red; + } + + &--blue { + fill: $line-color-blue; + } + + &--orange { + fill: $line-color-orange; + } + + &--green { + fill: $line-color-green; + } +} + +.end-slot__icon { + &--red { + stroke: $line-color-red; + } + + &--blue { + stroke: $line-color-blue; + } + + &--orange { + stroke: $line-color-orange; + } + + &--green { + stroke: $line-color-green; + } + + &--affected { + stroke: #171f26; + } +} + +.shuttle-stop { + stroke: #171f26; +} + +.middle-slot__background { + &--red { + fill: $line-color-red; + } + + &--blue { + fill: $line-color-blue; + } + + &--orange { + fill: $line-color-orange; + } + + &--green { + fill: $line-color-green; + } +} + +.middle-slot__icon { + &--red { + stroke: $line-color-red; + } + + &--blue { + stroke: $line-color-blue; + } + + &--orange { + stroke: $line-color-orange; + } + + &--green { + stroke: $line-color-green; + } +} + +.label--endpoint { + font-size: 35px; + font-weight: 700; + + .label { + font-weight: 400; + } +} + +.label-large { + font-family: Inter; + font-size: 30px; + font-weight: 500; + line-height: 35px; + + &--current-stop { + font-size: 35px; + font-weight: 700; + } + + &--small { + font-size: 25px; + } +} + +.label-small { + font-family: Inter; + font-size: 25px; + font-weight: 500; + line-height: 35px; + + &--current-stop { + font-size: 30px; + font-weight: 700; + } +} + +.station-closure-icon { + fill: #171f26; + + &--current-stop { + fill: #ee2e24; + } +} diff --git a/assets/css/v2/pre_fare/free_text.scss b/assets/css/v2/pre_fare/free_text.scss index a8c5064e3..791ef589a 100644 --- a/assets/css/v2/pre_fare/free_text.scss +++ b/assets/css/v2/pre_fare/free_text.scss @@ -54,3 +54,7 @@ line-height: unset; } } + +.free-text__string--nowrap { + white-space: nowrap; +} diff --git a/assets/css/v2/pre_fare/prefare_single_screen_alert.scss b/assets/css/v2/pre_fare/prefare_single_screen_alert.scss new file mode 100644 index 000000000..5bc659398 --- /dev/null +++ b/assets/css/v2/pre_fare/prefare_single_screen_alert.scss @@ -0,0 +1,215 @@ +@mixin large-text { + font-size: 112px; + line-height: 120px; + font-weight: 700; +} + +@mixin small-text { + font-size: 35px; + line-height: 42px; +} + +.pre-fare-alert__page { + display: flex; + flex-direction: column; + height: 100%; +} + +.alert-container--single-page { + font-family: Inter; + flex-grow: 1; + min-height: 0; + width: 1080px; + padding: 0 32px 32px 32px; + box-sizing: border-box; + + /* Alert contents */ + .alert-card { + height: 100%; + display: flex; + flex-direction: column; + box-shadow: 0px 10px 20px 0px rgba(23, 31, 38, 0.25); + background-color: #e5e4e1; + + &--no-banner { + border-radius: 4px; + } + + &--with-banner { + border-radius: 0 0 4px 4px; + } + } + + .alert-card__body { + min-height: 0; + flex-grow: 1; + padding: 120px 56px 32px 56px; + box-sizing: border-box; + &--shuttle { + padding-top: 80px; + padding-bottom: 0; + } + + .alert-card__content-block { + height: 100%; + display: flex; + flex-flow: column; + align-items: flex-start; + } + } + + .alert-card__issue, + .alert-card__remedy { + display: flex; + } + .alert-card__issue { + margin-bottom: 48px; + } + .alert-card__remedy { + margin-bottom: 32px; + &__text { + font-size: 74px; + font-weight: 600; + align-self: center; + } + } + + .alert-card__issue__location { + @include small-text(); + margin-top: 40px; + } + + .alert-card__remedy__shuttle-icons { + display: flex; + flex-direction: column; + } + + .alert-card__icon { + color: #171f26; + height: 124px; + min-width: 124px; + margin-right: 45px; + } + + .alert-card__content-block__text { + font-weight: 700; + &--large { + @include large-text(); + } + &--medium { + font-size: 86.4px; + line-height: 95px; + } + } + + .alert-card__content-block--downstream { + .alert-card__content-block__text { + line-height: 108px; + } + } + + .alert-card__fallback__icon { + height: 192px; + color: #171f26; + margin-bottom: 48px; + } + .alert-card__fallback__issue-text { + font-size: 88px; + line-height: 105.6px; + font-weight: 700; + margin-bottom: 54px; + } + .alert-card__fallback__pio-text { + font-size: 68px; + line-height: 81px; + font-weight: 500; + &--small { + font-size: 55.1px; + line-height: 65.63px; + } + &--bold { + font-weight: 700; + } + } + + .alert-card__body__accessibility-info { + &--text { + margin-top: 22px; + @include small-text(); + } + } + + .alert-card__isa-icon { + height: 40px; + margin: auto 45px 0 auto; + } + + .alert-card__footer { + flex-shrink: 0; + background: #cccbc8; + border-radius: 0px 0px 4px 4px; + height: 84px; + display: flex; + align-items: center; + box-sizing: border-box; + padding: 20px 60px; + font-size: 32px; + justify-content: right; + } + .alert-card__footer__cause { + flex-grow: 1; + } + .alert-card__footer__datetime { + font-weight: 600; + } + + .disruption-diagram-container { + width: 100%; + flex: 1; + margin-bottom: 64px; + min-height: 0; + } +} + +.alert-container--no-banner { + padding-top: 32px; +} + +.alert-container--right { + position: absolute; + top: 0; + left: 1080px; +} + +.alert-card__content-block__route-pill { + height: 124px; + display: inline; + vertical-align: bottom; + margin-right: 9px; + &:last-of-type { + margin-right: 46px; + } +} + +.alert-container--single-page { + /* Background colors */ + &.alert-container--blue { + background-color: $line-color-blue; + } + + &.alert-container--red { + background-color: $line-color-red; + } + + &.alert-container--orange { + background-color: $line-color-orange; + } + + &.alert-container--yellow { + background-color: $alert-yellow; + } + + &.alert-container--green { + background-color: $line-color-green; + } +} diff --git a/assets/css/v2/pre_fare/reconstructed_alert.scss b/assets/css/v2/pre_fare/reconstructed_alert.scss index c144da14c..4fb37a307 100644 --- a/assets/css/v2/pre_fare/reconstructed_alert.scss +++ b/assets/css/v2/pre_fare/reconstructed_alert.scss @@ -1,5 +1,5 @@ -$takeover-card-height: 1600px; -$takeover-card-footer-height: 176px; +$takeover-card-height: 1656px; +$takeover-card-footer-height: 84px; $alert-card-height: 576px; $alert-card-footer-height: 80px; @@ -44,8 +44,12 @@ $alert-card-footer-height: 80px; padding: 40px 120px 20px 44px; box-sizing: border-box; - .bold { font-weight: 800; } - .medium-bold { font-weight: 500; } + .bold { + font-weight: 800; + } + .medium-bold { + font-weight: 500; + } } .alert-card__body__content { padding: 56px 0 0 8px; @@ -92,7 +96,7 @@ $alert-card-footer-height: 80px; margin-left: 16px; } } - + .route-pill__text { position: relative; width: fit-content; @@ -167,73 +171,134 @@ $alert-card-footer-height: 80px; color: white; height: 100%; width: 1080px; - padding: 60px; + padding: 32px; box-sizing: border-box; /* Alert contents */ .alert-card { - width: 960px; - height: $takeover-card-height; - border-radius: 32px; - box-shadow: 0px 10px 20px 0px rgba(23, 31, 38, 0.25); - background-color: #171f26; - } + width: 1016px; + height: 100%; + border-radius: 4px; + background-color: #e5e4e1; + color: #171f26; - .alert-card__body { - height: $takeover-card-height - $takeover-card-footer-height; - padding: 72px; - box-sizing: border-box; - } + &__body { + height: $takeover-card-height - $takeover-card-footer-height; + padding: 0 56px; + box-sizing: border-box; + font-family: Inter, sans-serif; + font-size: 200px; + font-weight: 800; - .alert-card__body__icon { - height: 280px; - padding-bottom: 80px; - } + .container { + height: 100%; + display: flex; + flex-flow: column; + align-items: flex-start; + } - .alert-card__body__location { - margin: 40px 0 24px 0; - font-weight: 800; - text-transform: capitalize; - } + &__icon { + flex-shrink: 0; + height: 300px; + margin-bottom: 48px; + padding-top: 112px; + } - .alert-card__body__cause { - font-weight: 500; - text-transform: capitalize; - } + &__issue { + width: 904px; + margin-bottom: 32px; + line-height: 220px; + } - .alert-card__body__remedy { - font-weight: 800; - padding-top: 352px; - } + &__location { + font-size: 60px; + font-weight: 400; + margin-bottom: 32px; + } - .alert-card__body__accessibility-info { - margin-top: 90px; - display: flex; - &--text { - width: 524px; - color: #e6e4e1; + &__cause { + font-weight: 500; + text-transform: capitalize; + } + + &__remedy { + padding-top: 474px; + height: 660px; + line-height: 220px; + } + + &__shuttle-icon { + height: 300px; + padding-top: 112px; + } + + &__shuttle-remedy { + height: 660px; + line-height: 220px; + } + + &__accessibility-info { + margin-top: 34px; + display: flex; + &--text { + font-weight: 400; + font-size: 53px; + min-width: 811px; + color: #000000; + } + } + + &__isa-icon { + height: 61px; + padding-right: 33px; + } + + .disruption-diagram-container { + width: 904px; + flex: 1; + min-height: 0; + } } - } - .alert-card__body__isa-icon { - height: 136px; - padding-right: 36px; - } - - .alert-card__footer { - background-color: #2e3e4d; - border-radius: 0px 0px 32px 32px; - height: $takeover-card-footer-height; - } - .alert-card__footer__t-icon { - height: 102px; - padding: 37px 47px; - } - .alert-card__footer__alerts-url { - font-size: 64px; - font-weight: 800; - text-align: right; - padding: 41px 52px 55px 0; + &__footer { + color: #000000; + background-color: #cccbc8; + border-radius: 0px 0px 4px 4px; + height: $takeover-card-footer-height; + font-size: 32px; + font-weight: 400; + position: relative; + text-align: center; + + &__cause { + position: absolute; + bottom: 20px; + left: 60px; + height: 44px; + margin-top: 20px; + text-transform: capitalize; + } + + &__updated-at { + position: absolute; + bottom: 20px; + right: 60px; + height: 44px; + text-align: end; + margin-top: 20px; + + .bold { + font-weight: 600; + } + } + + &__alerts-url { + position: absolute; + bottom: 20px; + right: 60px; + text-align: right; + } + } } } @@ -255,13 +320,14 @@ $alert-card-footer-height: 80px; font-weight: 500; } .alert-card__body__route-pills - .route-pills__branches - .route-pills__branches__dot { + .route-pills__branches + .route-pills__branches__dot { background-color: #171f26; } } -.alert-container--takeover, .alert-container--urgent { +.alert-container--takeover, +.alert-container--urgent { /* Background colors */ &.alert-container--blue { background-color: $line-color-blue; diff --git a/assets/css/v2/pre_fare/simulation.scss b/assets/css/v2/pre_fare/simulation.scss index 8ddc30582..ad86ea69a 100644 --- a/assets/css/v2/pre_fare/simulation.scss +++ b/assets/css/v2/pre_fare/simulation.scss @@ -5,7 +5,7 @@ } .simulation-screen-scrolling-container { - scrollbar-color: #607180 #2E3F4D; + scrollbar-color: #607180 #2e3f4d; &::-webkit-scrollbar { width: 12px; @@ -13,12 +13,12 @@ &::-webkit-scrollbar-thumb { background-color: #607180; border-radius: 12px; - border: 2px solid #2E3F4D; + border: 2px solid #2e3f4d; } &::-webkit-scrollbar-track { -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); border-radius: 12px; - background-color: #2E3F4D; + background-color: #2e3f4d; } } @@ -26,99 +26,91 @@ display: flex; overflow-y: hidden; overflow-x: auto; - padding-bottom: 20px; + margin-bottom: 20px; + height: 449px; - .simulation__full-page { - height: 344px; - width: 387px; - margin-right: 24px; - z-index: 999; - box-shadow: 0px 0px 32px 0px #00000040; - - & > * { - transform-origin: top left; - transform: scale(17.92%); - } + .simulation { + transform-origin: top left; + transform: scale(17.92%); } - .simulation__flex-zone { - background: #2e3f4d; - display: flex; - margin: auto 2px auto 0; - height: 160px; - border-radius: 0 8px 8px 0; - position: relative; + .simulation__full-page { + max-height: 407px; + max-width: 388px; + margin: 16px; + z-index: 999; - &:before { - content: ""; - position: absolute; - right: 100%; - // NO HEIGHT - border-top: 80px solid transparent; - border-right: 100px solid #2e3f4d; - border-bottom: 80px solid transparent; + .simulation__title { + font-family: Inter; + font-size: 16px; + font-weight: 700; + line-height: 24px; + color: #f8f9fa; + margin-bottom: 10px; } } - .simulation__flex-zone-widget { - width: 256px; - height: 144px; - margin-top: auto; - margin-bottom: auto; + .simulation__left-screen { + height: 375px; + margin: 32px 16px 16px 16px; - .alert-container--urgent { - background-color: unset; - } + .simulation__left-screen-widget-container { + display: flex; - &:last-child { - margin-right: 8px; + .simulation__left-screen-widget { + width: 194px; + margin-right: 24px; + & > * { + height: 1720px; + width: 1080px; + overflow: hidden; + } + } } - &:not(:last-child) { - margin-right: 24px; + .simulation__title { + font-family: Inter; + font-size: 16px; + font-weight: 400; + line-height: 24px; + color: #f8f9fa; + margin-bottom: 10px; } + } - .flex-one-large, - .flex-two-medium { - transform-origin: top left; - transform: scale(25%); - - .flex-one-large__large { - .evergreen-content-image__container, - .evergreen-content-image__image { - max-width: 1024px; - max-height: 576px; - } - } - - .flex-two-medium__left, - .flex-two-medium__right { - .evergreen-content-image__container, - .evergreen-content-image__image { - max-width: 480px; - max-height: 580px; - } - } + .simulation__right-screen { + height: 375px; + margin: 128px 16px 16px 16px; - & > * { - position: unset; + .simulation__right-screen-widget-container { + display: flex; - // Pre-Fare flex-zones are a little weird. - // For some reason, some widgets have padding or margin that throw off the positioning in the sim. - // Removing margin and padding in the widget fixes it. + .simulation__right-screen-widget { + width: 194px; + margin-right: 24px; & > * { - margin: 0; - padding: 0; + height: 1720px; + width: 1080px; + overflow: hidden; } } } - .flex-two-medium { - display: flex; - - .flex-two-medium__left { - margin-right: 40px; - } + .simulation__title { + font-family: Inter; + font-size: 16px; + font-weight: 400; + line-height: 24px; + color: #f8f9fa; + margin-bottom: 10px; } } + + .divider:after { + content: ""; + position: absolute; + border-left: 1px solid #1e2933; + top: 48px; + height: 343px; + } } diff --git a/assets/src/apps/v2/pre_fare.tsx b/assets/src/apps/v2/pre_fare.tsx index 59bf1126c..a3333055a 100644 --- a/assets/src/apps/v2/pre_fare.tsx +++ b/assets/src/apps/v2/pre_fare.tsx @@ -47,12 +47,14 @@ import ReconstructedTakeover from "Components/v2/reconstructed_takeover"; import CRDepartures from "Components/v2/cr_departures/cr_departures"; import OvernightCRDepartures from "Components/v2/cr_departures/overnight_cr_departures"; import MultiScreenPage from "Components/v2/multi_screen_page"; -import SimulationScreenPage from "Components/v2/simulation_screen_page"; +import SimulationScreenPage from "Components/v2/pre_fare/simulation_screen_page"; import SurgeBodyRight from "Components/v2/pre_fare/surge_body_right"; import ShuttleBusInfo from "Components/v2/shuttle_bus_info"; import BlueBikes from "Components/v2/blue_bikes"; +import PreFareSingleScreenAlert from "Components/v2/pre_fare_single_screen_alert"; const TYPE_TO_COMPONENT = { + // Slots screen_normal: NormalScreen, screen_takeover: ScreenTakeover, screen_split_takeover: ScreenSplitTakeover, @@ -67,6 +69,7 @@ const TYPE_TO_COMPONENT = { normal_header: NormalHeader, one_large: OneLarge, two_medium: TwoMedium, + // Widgets placeholder: Placeholder, evergreen_content: EvergreenContent, elevator_status: ElevatorStatus, @@ -75,6 +78,7 @@ const TYPE_TO_COMPONENT = { no_data: NoData, page_load_no_data: PageLoadNoData, reconstructed_large_alert: ReconstructedAlert, + single_screen_alert: PreFareSingleScreenAlert, reconstructed_takeover: ReconstructedTakeover, cr_departures: CRDepartures, overnight_cr_departures: OvernightCRDepartures, diff --git a/assets/src/components/v2/disruption_diagram/disruption_diagram.tsx b/assets/src/components/v2/disruption_diagram/disruption_diagram.tsx index 607635720..6528e7e60 100644 --- a/assets/src/components/v2/disruption_diagram/disruption_diagram.tsx +++ b/assets/src/components/v2/disruption_diagram/disruption_diagram.tsx @@ -1,6 +1,54 @@ -import React, { ComponentType } from "react"; +// SPECIFICATION: https://www.notion.so/mbta-downtown-crossing/Disruption-Diagram-Specification-a779027385b545abbff6fb4b4fd0adc1 -type DisruptionDiagramData = ContinuousDisruptionDiagram | DiscreteDisruptionDiagram; +import React, { ComponentType, useCallback, useEffect, useRef, useState } from "react"; +import { classWithModifier, classWithModifiers } from "Util/util"; + +import LargeXOctagonBordered from "../../../../static/images/svgr_bundled/disruption_diagram/large-x-octagon-bordered.svg"; +import SmallXOctagon from "../../../../static/images/svgr_bundled/disruption_diagram/small-x-octagon.svg"; +import CurrentStopDiamond from "../../../../static/images/svgr_bundled/disruption_diagram/current-stop-diamond.svg"; +import CurrentStopOpenDiamond from "../../../../static/images/svgr_bundled/disruption_diagram/current-stop-open-diamond.svg"; +import ArrowLeftEndpoint from "../../../../static/images/svgr_bundled/disruption_diagram/arrow-left-endpoint.svg"; +import ArrowRightEndpoint from "../../../../static/images/svgr_bundled/disruption_diagram/arrow-right-endpoint.svg"; +import ShuttleBusIcon from "../../../../static/images/svgr_bundled/disruption_diagram/shuttle-emphasis-icon.svg"; + +// Width of the disruption diagram, dependent on the screen width +const DIAGRAM_WIDTH = 904; +const SLOT_WIDTH = 24; +// Height of the colored line for the diagram +const LINE_HEIGHT = 24; +const EMPHASIS_HEIGHT = 80; +// This padding is only used in 1 spot, and it may not be the most accurate measure +// of the padding above the emphasis. Keeping for now +const EMPHASIS_PADDING_TOP = 8; +// The tallest icon (the diamond) is used in translation calculations +const MAX_ENDPOINT_HEIGHT = 64; +const LARGE_X_STOP_ICON_HEIGHT = 48; +// L: the amount by which the left end extends beyond the leftmost station slot. +// R: the width by which the right end extends beyond the rightmost station slot. +// L can vary based on whether the first slot is an arrow vs diamond, because the diamond is larger. +// Would be nice if this was programmatic, but this works for now +const L = MAX_ENDPOINT_HEIGHT / 2; +const R = 165; +// The width taken up by the ends outside the typical station bounds is L + R, +// so the width available to the rest of the diagram is DIAGRAM_WIDTH - (L + R) +const W = DIAGRAM_WIDTH - (L + R); + +// List of abbreviated stations +const abbreviationList: {[string: string]: string} = { + "Boston University Center": "BU Central", + "Boston University East": "BU East", + "Downtown Crossing": "Downtown Xng", + "Government Center": "Gov't Center", + "Hynes Convention Center": "Hynes", + "Massachusetts Avenue": "Mass Ave", + "Tufts Medical Center": "Tufts Medical Ctr", + "…via Government Center": "…via Gov't Center", + "…via Downtown Crossing": "…via Downtown Xng" +} + +type DisruptionDiagramData = + | ContinuousDisruptionDiagram + | DiscreteDisruptionDiagram; interface DisruptionDiagramBase { line: LineColor; @@ -45,22 +93,503 @@ interface MiddleSlot { } // Note the single ellipsis character, not 3 periods -type Label = "…" | { full: string, abbrev: string }; +type Label = "…" | { full: string; abbrev: string }; + +type LineColor = "blue" | "orange" | "red" | "green"; + +type Effect = "shuttle" | "suspension" | "station_closure"; // End labels have hardcoded presentation, so we just send an ID for the client to use in // a lookup. // -// TBD what these IDs will look like. We might just use parent station IDs. -// // The rest of the labels' presentations are computed based on the height of the end labels, // so we can send actual text for those--it will be dynamically resized to fit. type EndLabelID = string; -type LineColor = - | "blue" - | "orange" - | "red" - | "green"; +// If value is length === 2, label is split onto 2 lines. +const endLabelIDMap: { [labelID: string]: string[] } = { + "place-bomnl": ["BOWDOIN"], + "place-wondl": ["WONDERLAND"], + "place-alfcl": ["ALEWIFE"], + "place-asmnl+place-brntn": ["ASHMONT &", "BRAINTREE"], + "place-asmnl": ["ASHMONT"], + "place-brntn": ["BRAINTREE"], + "place-ogmnl": ["OAK GROVE"], + "place-forhl": ["FOREST", "HILLS"], + "place-gover": ["GOVERNMENT", "CENTER"], + "place-lake": ["BOSTON COLLEGE"], + "place-clmnl": ["CLEVELAND CIR"], + "place-unsqu": ["UNION SQUARE"], + "place-river": ["RIVERSIDE"], + "place-mdftf": ["MEDFORD/TUFTS"], + "place-hsmnl": ["HEATH ST"], + "place-kencl": ["KENMORE"], + "place-kencl+west": ["KENMORE & WEST"], + "place-mdftf+place-unsqu": ["MEDFORD/TUFTS", "& UNION SQ"], + "place-north+place-pktrm": ["NORTH STATION", "& PARK ST"], + "place-coecl+west": ["COPLEY & WEST"], + western_branches: ["WESTERN BRANCHES"], +}; + +interface IconProps { + iconSize: number; +} + +interface EndpointProps { + className: string; +} + +// Non-circle icons are translated by their top-left corner, while circles +// are translated by their center-point. So to position these non-circles, +// translate is x shifted by half the width of the icon, and y is shifted up half its +// iconsize and half the thickness of the line diagram itself +const translateNonCircleIcon = (iconSize: number) => + `translate(-${iconSize / 2} -${(iconSize - LINE_HEIGHT) / 2})`; + +// Special current stop icon for the red line: hollow red diamond +const CurrentStopOpenDiamondIcon: ComponentType = ({ iconSize }) => { + return ( + + + + ); +}; + +// Current stop icon for all other lines: solid red diamond +const CurrentStopDiamondIcon: ComponentType = ({ iconSize }) => { + return ( + + + + ); +}; + +// This is the x-octagon without a border +const SmallXStopIcon: ComponentType = ({ iconSize }) => { + return ( + + + + ); +}; + +// This is the x-octagon with a border +const LargeXStopIcon: ComponentType<{ iconSize: number; color?: string }> = ({ + iconSize, + color, +}) => { + return ( + + + + ); +}; + +// Basic template for a Circle Icon +const CircleStopIcon: ComponentType<{ + r: number; + className: string; + strokeWidth: number; +}> = ({ r, className, strokeWidth }) => ( + +); + +const CircleShuttlingStopIcon: ComponentType<{}> = () => ( + +); + +const CircleStopIconEndpoint: ComponentType = ({ + className, +}) => ; + +const LeftArrowEndpoint: ComponentType = ({ className }) => ( + +); + +const RightArrowEndpoint: ComponentType = ({ className }) => ( + +); + +const EndpointLabel: ComponentType<{ labelID: string; isArrow: boolean }> = ({ + labelID, + isArrow, +}) => { + let labelParts = endLabelIDMap[labelID]; + if (labelParts.length === 1) { + return ( + + {isArrow && to } + {labelParts[0]} + + ); + } else { + return ( + <> + + {isArrow && to } + {labelParts[0].includes("&") ? ( + <> + {labelParts[0].replace(" &", "")} + & + + ) : ( + labelParts[0] + )} + + + {labelParts[1].includes("&") ? ( + <> + & + {labelParts[1].replace("& ", "")} + + ) : ( + labelParts[1] + )} + + + ); + } +}; + +interface EndSlotComponentProps { + slot: EndSlot; + line: LineColor; + isCurrentStop: boolean; + isAffected: boolean; + effect: Effect; + spaceBetween: number; + isLeftSide: boolean; + x: number; +} + +const EndSlotComponent: ComponentType = ({ + slot, + line, + isCurrentStop, + isAffected, + effect, + spaceBetween, + isLeftSide, + x, +}) => { + let icon; + if (slot.type === "arrow") { + icon = isLeftSide ? ( + + + + ) : ( + + ); + } else if (isAffected && isCurrentStop) { + icon = ; + } else if (isAffected && effect != "shuttle") { + icon = ; + } else if (isCurrentStop && line === "red") { + icon = ; + } else if (isCurrentStop) { + icon = ; + } else { + const modifiers = [line.toString()]; + if (isAffected) { + modifiers.push("affected"); + } + icon = ( + + ); + } + + let background; + if ( + (!isAffected && isLeftSide) || + (effect === "station_closure" && isLeftSide) + ) { + background = ( + + ); + } else { + background = <>; + } + + return ( + + {background} + {icon} + + + ); +}; + +interface MiddleSlotComponentProps { + slot: MiddleSlot; + x: number; + spaceBetween: number; + line: LineColor; + isCurrentStop: boolean; + isAffected: boolean; + effect: Effect; + firstAffectedIndex: boolean; + abbreviate: boolean; + labelTextClass: string; +} + +const MiddleSlotComponent: ComponentType = ({ + slot, + x, + spaceBetween, + line, + isCurrentStop, + isAffected, + effect, + firstAffectedIndex, + abbreviate, + labelTextClass, +}) => { + const { label } = slot; + let background; + // Background for suspension/shuttle is drawn in EffectBackgroundComponent. + if (isAffected && effect !== "station_closure") { + background = <>; + } else { + background = ( + + ); + } + + let icon; + if (slot.show_symbol) { + if (isCurrentStop) { + if (isAffected) { + icon = ; + } else { + icon = + line === "red" ? ( + + ) : ( + + ); + } + } else { + if (isAffected && !firstAffectedIndex) { + switch (effect) { + case "suspension": + icon = ; + break; + case "station_closure": + icon = ; + break; + case "shuttle": + if (label !== "…" && label.full === "Beaconsfield") { + icon = ; + } else { + icon = ; + } + } + } else { + icon = ( + + ); + } + } + } else { + icon = <>; + } + + let textModifier; + + if (isCurrentStop) { + textModifier = "current-stop"; + } + + return ( + + {background} + {icon} + {label === "…" ? ( + + {" "} + {label}{" "} + + ) : ( + + {abbreviate && Object.keys(abbreviationList).includes(label.full) + ? abbreviationList[label.full] + : label.full} + + )} + + ); +}; + +interface EffectBackgroundComponentProps { + effectRegionSlotIndexRange: + | [range_start: number, range_end: number] + | number[]; + effect: Effect; + spaceBetween: number; +} + +// Only for shuttles or suspensions +const EffectBackgroundComponent: ComponentType< + EffectBackgroundComponentProps +> = ({ spaceBetween, effect, effectRegionSlotIndexRange }) => { + const rangeStart = effectRegionSlotIndexRange[0]; + const rangeEnd = effectRegionSlotIndexRange[1]; + + const x1 = rangeStart * (spaceBetween + SLOT_WIDTH); + const x2 = (spaceBetween + SLOT_WIDTH) * rangeEnd; + const heightOfBackground = 16; + + let background; + if (effect === "shuttle") { + const dashXunit = (spaceBetween + SLOT_WIDTH) / 18; + const dash = dashXunit * 4; + const gap = dashXunit * 2; + background = ( + + ); + } else { + background = ( + + ); + } + + return <>{background}; +}; + +interface AlertEmphasisComponentProps { + effectRegionSlotIndexRange: + | [range_start: number, range_end: number] + | number[]; + spaceBetween: number; + effect: "suspension" | "shuttle"; + scaleFactor: number; +} + +const AlertEmphasisComponent: ComponentType = ({ + effectRegionSlotIndexRange, + spaceBetween, + effect, + scaleFactor, +}) => { + const rangeStart = effectRegionSlotIndexRange[0]; + const rangeEnd = effectRegionSlotIndexRange[1]; + + const x1 = rangeStart * (spaceBetween + SLOT_WIDTH) * scaleFactor; + const x2 = (spaceBetween + SLOT_WIDTH) * rangeEnd * scaleFactor; + + const middleOfLine = (x2 - x1) / 2 + x1; + const endLinesHeight = 24; + const endLinesStrokeWidth = 8; + + let icon; + if (effect === "shuttle") { + icon = ( + + + + ); + } else if (effect === "suspension") { + icon = ( + + + + ); + } + + return ( + <> + {effectRegionSlotIndexRange[1] - effectRegionSlotIndexRange[0] + 1 > + 2 && ( + <> + + + + + )} + {icon} + + ); +}; /* Client is responsible for: @@ -71,10 +600,241 @@ Client is responsible for: - sizing, spacing, positioning of edges/end arrows/shuttle dashes/the diagram as a whole within its container */ -const DisruptionDiagram: ComponentType = (_props) => { +const DisruptionDiagram: ComponentType = (props) => { + const { slots, current_station_slot_index, line, effect } = props; + const [doAbbreviate, setDoAbbreviate] = useState(false); + const [scaleFactor, setScaleFactor] = useState(1); + const [isDone, setIsDone] = useState(false); + // Get the size of the diagram line map svg, excluding emphasis + const [lineDiagramHeight, setLineDiagramHeight] = useState(0); + const [lineDiagramWidth, setLineDiagramWidth] = useState(0); + // A ref on the diagram container will indicate how much room we have to scale the map + const [diagramContainerHeight, setDiagramContainerHeight] = useState(0); + const [simulationTransform, setSimulationTransform] = useState(1); + + const ref = useRef(null); + useEffect(() => { + if (!ref.current) return; + const resizeObserver = new ResizeObserver(() => { + if (ref?.current) { + setDiagramContainerHeight(ref.current.clientHeight * simulationTransform); + } + }); + resizeObserver.observe(ref.current); + return () => resizeObserver.disconnect(); + }, [ref?.current]); + + // Measures line-map svg when the scaleFactor changes, updates state + const measureLineMapNode = useCallback(node => { + if (node !== null) { + const {height, width} = node.getBoundingClientRect(); + setLineDiagramHeight(height); + setLineDiagramWidth(width); + } + }, [scaleFactor]); + + // First, we need to figure out whether we're in a Screenplay simulation or not, + // because unfortunately, the CSS transform on those simulations messes up the widget's + // ability to measure itself in the DOM. So, we need to get the original height of the + // diagram, pre-scaled, to accurately set its viewbox dimensions. + useEffect(() => { + const simulation = document.getElementById("simulation") + const simulationStyle = simulation && window.getComputedStyle(simulation) + setSimulationTransform(new DOMMatrix(simulationStyle?.transform).m11); + }, []); + + const fullWidth = 904 * simulationTransform; + const originalHeight = lineDiagramHeight * 1/simulationTransform + + const numStops = slots.length; + const spaceBetween = Math.min( + 60, + (W - SLOT_WIDTH * numStops) / (numStops - 1) + ); + const [beginning, middle, end] = [slots[0], slots.slice(1, -1), slots.at(-1)]; + const hasEmphasis = effect !== "station_closure"; + + const getEmphasisHeight = (scale: number) => ( + hasEmphasis + ? EMPHASIS_HEIGHT + EMPHASIS_PADDING_TOP * scale + : 0 + ) + + const labelTextClass = slots.length > 12 ? "small" : "large"; + + let x = 0; + const middleSlots = middle.map((s, i) => { + // Add 1 to the index to counteract the offset caused by removing `beginning` from the original `slots` array. + const slotIndex = i + 1; + x = (spaceBetween + SLOT_WIDTH) * slotIndex; + const slot = s as MiddleSlot; + const key = slot.label === "…" ? i : slot.label.full; + const isAffected = + effect === "station_closure" + ? props.closed_station_slot_indices.includes(slotIndex) + : slotIndex >= props.effect_region_slot_index_range[0] && + slotIndex <= props.effect_region_slot_index_range[1] - 1; + + return ( + + ); + }); + + x += spaceBetween + SLOT_WIDTH; + + // When the parent container size changes, or abbreviation setting changes, + // re-measure the diagram and scale accordingly. + useEffect(() => { + // Scale the line-map svg given the available screen width + const measureDiagramAndScale = () => { + if (!isDone && diagramContainerHeight != 0) { + // If scaleFactor has already been applied to the line-map, we need to reverse that for calculations + const unscaledHeight = lineDiagramHeight / scaleFactor; + const unscaledWidth = lineDiagramWidth / scaleFactor; + + // First, scale x. Then, check if it needs abbreviating. Then scale y, given the abbreviation + const xScaleFactor = fullWidth / unscaledWidth; + + // If xScaleFactor is less than 1, let's try abbreviating. + // Or, if the x scaling constrains the height, abbreviate + const needsAbbreviating = !doAbbreviate && (xScaleFactor < 1 || + unscaledHeight * xScaleFactor + getEmphasisHeight(xScaleFactor) * simulationTransform > diagramContainerHeight); + if (needsAbbreviating) { + setDoAbbreviate(true); + // now scale y, which requires re-running this effect + } else { + const yScaleFactor = (diagramContainerHeight - getEmphasisHeight(1) * simulationTransform) / unscaledHeight + const factor = Math.min( + xScaleFactor, + yScaleFactor + ); + setScaleFactor(factor); + setIsDone(true); + } + } + } + + // The isCurrent setting is needed to clean up the unused hook runs / state changes + let isCurrent = true + + // The document.fonts.ready.then() is needed when the font takes a while to load + // but the diagram measurements have already been taken. + // Example: shuttle Chinatown > Mass Ave, screen located at Back Bay + document.fonts.ready.then(() => { + if (isCurrent) measureDiagramAndScale() + }) + return () => { + isCurrent = false + } + }, [lineDiagramHeight, diagramContainerHeight, doAbbreviate]); + + // This is to center the diagram along the X axis + const translateX = (lineDiagramWidth && (fullWidth - lineDiagramWidth) / 2 / simulationTransform) || 0; + + // Next is to align the diagram at the top of the svg, which involves adjusting the SVG viewbox + + // If -${height} is used as the viewbox height, it looks like the line diagram text + // pushed all the way to the bottom of the viewbox with just a tiny point of the + // "You are Here" diamond sticking out. So, the parts that are cut off are the whole + // height of the line diagram, and a little extra for the bottom of the "You are Here" diamond. + + // To calculate the height of that missing part, that is: + // LINE_HEIGHT*scaleFactor/2 - MAX_ENDPOINT_HEIGHT*scaleFactor/2 + (hasEmphasis ? EMPHASIS_PADDING_TOP * scaleFactor : 0) + + // So finally, the vertical viewBoxOffset is parent container height minus + // all the stuff below the very top of the line diagram + const viewBoxOffset = + originalHeight + - LINE_HEIGHT * scaleFactor / 2 + - MAX_ENDPOINT_HEIGHT * scaleFactor / 2 + return ( -
Diagram goes here!
+
+ + + + {effect !== "station_closure" && ( + + )} + + {middleSlots} + + + {hasEmphasis && ( + + + + )} + + +
); }; +export { DisruptionDiagramData }; + export default DisruptionDiagram; diff --git a/assets/src/components/v2/pre_fare/simulation_screen_container.tsx b/assets/src/components/v2/pre_fare/simulation_screen_container.tsx new file mode 100644 index 000000000..08b3c0671 --- /dev/null +++ b/assets/src/components/v2/pre_fare/simulation_screen_container.tsx @@ -0,0 +1,113 @@ +import React, { ComponentType, useContext } from "react"; +import { + LastFetchContext, + ResponseMapperContext, +} from "Components/v2/screen_container"; +import Widget, { WidgetData } from "Components/v2/widget"; +import { + ApiResponse, + useSimulationApiResponse, +} from "Hooks/v2/use_api_response"; +import WidgetTreeErrorBoundary from "Components/v2/widget_tree_error_boundary"; + +interface SimulationScreenLayoutProps { + apiResponse: ApiResponse; +} + +const SimulationScreenLayout: ComponentType = ({ + apiResponse, +}) => { + const responseMapper = useContext(ResponseMapperContext); + const data = responseMapper(apiResponse); + const { fullPage, flexZone } = data; + let leftScreenPages: WidgetData[] = []; + let rightScreenPages: WidgetData[] = []; + if (flexZone) { + leftScreenPages = flexZone.filter( + (widget: WidgetData) => widget.type === "body_left_flex" + ); + rightScreenPages = flexZone.filter( + (widget: WidgetData) => !leftScreenPages.includes(widget) + ); + } + + const isPageListActive = leftScreenPages && leftScreenPages.length > 1 + && rightScreenPages && rightScreenPages.length > 1 + + return ( +
+
+ {apiResponse && ( +
+
Live view
+
+ + + +
+
+ )} + {isPageListActive &&
} + {leftScreenPages && leftScreenPages.length > 1 && ( +
+
+ Left panel ({leftScreenPages.length}) +
+
+ {leftScreenPages.map( + (flexZonePage: WidgetData, index: number) => { + return ( +
+ +
+ ); + } + )} +
+
+ )} + {rightScreenPages && rightScreenPages.length > 1 && ( +
+
+ Flex zone ({rightScreenPages.length}) +
+
+ {rightScreenPages.map( + (flexZonePage: WidgetData, index: number) => { + return ( +
+ +
+ ); + } + )} +
+
+ )} +
+
+ ); +}; + +const SimulationScreenContainer = ({ + id, +}: { + id: string; + opts?: { [key: string]: any }; +}) => { + const { apiResponse, lastSuccess } = useSimulationApiResponse({ id }); + + return ( + + + + ); +}; + +export default SimulationScreenContainer; diff --git a/assets/src/components/v2/pre_fare/simulation_screen_page.tsx b/assets/src/components/v2/pre_fare/simulation_screen_page.tsx new file mode 100644 index 000000000..1fca8bde4 --- /dev/null +++ b/assets/src/components/v2/pre_fare/simulation_screen_page.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { useParams } from "react-router-dom"; +import SimulationScreenContainer from "Components/v2/pre_fare/simulation_screen_container"; + +const SimulationScreenPage = ({ opts = {} }) => { + const { id } = useParams() as { id: string }; + return ; +}; + +export default SimulationScreenPage; diff --git a/assets/src/components/v2/pre_fare_single_screen_alert.tsx b/assets/src/components/v2/pre_fare_single_screen_alert.tsx new file mode 100644 index 000000000..91bf92ebc --- /dev/null +++ b/assets/src/components/v2/pre_fare_single_screen_alert.tsx @@ -0,0 +1,575 @@ +import useTextResizer from "Hooks/v2/use_text_resizer"; +import React, { useEffect, useRef, useState } from "react"; +import { getHexColor, STRING_TO_SVG } from "Util/svg_utils"; +import DisruptionDiagram, { + DisruptionDiagramData, +} from "./disruption_diagram/disruption_diagram"; +import { classWithModifier, classWithModifiers, formatCause } from "Util/util"; + +import ClockIcon from "../../../static/images/svgr_bundled/clock-negative.svg"; +import NoServiceIcon from "../../../static/images/svgr_bundled/no-service.svg"; +import InfoIcon from "../../../static/images/svgr_bundled/info.svg"; +import ISAIcon from "../../../static/images/svgr_bundled/isa.svg"; +import WalkingIcon from "../../../static/images/svgr_bundled/nearby.svg"; +import ShuttleBusIcon from "../../../static/images/svgr_bundled/bus.svg"; + +interface PreFareSingleScreenAlertProps { + issue: string; + location: string; + cause: string; + remedy: string; + remedy_bold?: string; + routes: EnrichedRoute[]; + unaffected_routes: EnrichedRoute[]; + endpoints: string[]; + effect: string; + region: string; + updated_at: string; + disruption_diagram?: DisruptionDiagramData; +} + +interface EnrichedRoute { + route_id: string; + svg_name: string; +} + +interface StandardLayoutProps { + issue: string; + remedy: string; + effect: string; + location: string | null; + disruptionDiagram?: DisruptionDiagramData; +} + +// Bypassed station alerts can have resizing font based on how many stations are affected +// Other alerts have static font sizes: +// - issue font is size large +// - "Seek alternate route" remedy is medium +// - "Use shuttle bus" remedy is large +const StandardLayout: React.ComponentType = ({ + issue, + remedy, + effect, + location, + disruptionDiagram, +}) => { + const maxTextHeight = 772 + + const { ref: contentBlockRef, size: contentTextSize } = useTextResizer({ + sizes: ["medium", "large"], + // the 32 is padding on the text object + maxHeight: maxTextHeight + 32, + resetDependencies: [issue, remedy], + }); + + return ( +
+
+ + +
+ {disruptionDiagram && ( + + )} +
+ ); +}; + +interface DownstreamLayoutProps { + endpoints: string[]; + effect: string; + remedy: string; + disruptionDiagram?: DisruptionDiagramData; +} + +// In the downstream layout, the map is at the top, and the font size stays constant +const DownstreamLayout: React.ComponentType = ({ + endpoints, + effect, + remedy, + disruptionDiagram, +}) => ( +
+ {disruptionDiagram && } + + +
+); + +interface MultiLineLayoutProps { + routes: EnrichedRoute[]; + unaffected_routes: EnrichedRoute[]; + disruptionDiagram?: DisruptionDiagramData; +} + +// Covers the case where a station_closure only affects one line at a transfer station. +// In the even rarer case that there are multiple branches in the routes list or unaffected routes list +// the font size may need to shrink to accommodate. +const MultiLineLayout: React.ComponentType = ({ + routes, + unaffected_routes, + disruptionDiagram, +}) => { + const AffectedLinePill = STRING_TO_SVG[routes[0].svg_name]; + const affectedLineColor = getHexColor(getRouteColor(routes[0].route_id)); + + return ( +
+
+ +
+ + trains are skipping this station +
+
+
+ +
+ {unaffected_routes.map((route) => { + const UnaffectedLinePill = STRING_TO_SVG[route.svg_name]; + const unaffectedLineColor = getHexColor( + getRouteColor(route.route_id) + ); + return ( + + ); + })} + trains stop as usual +
+
+ {disruptionDiagram && ( + + )} +
+ ); +}; + +interface FallbackLayoutProps { + issue: string; + remedy: string; + remedyBold?: string; + effect: string; +} + +const FallbackLayout: React.ComponentType = ({ + issue, + remedy, + remedyBold, + effect, +}) => { + const { ref: pioTextBlockRef, size: pioSecondaryTextSize } = useTextResizer({ + sizes: ["small", "medium"], + maxHeight: 460, + resetDependencies: [issue, remedy], + }); + + const icon = + effect === "delay" ? ( + + ) : effect === "shuttle" ? ( + + ) : ( + + ); + + return ( +
+ {icon} + {issue &&
{issue}
} + {remedy && ( +
+ {remedy} +
+ )} + {remedyBold && +
+ {remedyBold} +
+ } +
+ ); +}; + +interface StandardIssueSectionProps { + issue: string; + location: string | null; + contentTextSize: string; +} + +const StandardIssueSection: React.ComponentType = ({ + issue, + location, + contentTextSize, +}) => ( +
+ +
+
+ {issue} +
+ {location && ( +
{location}
+ )} +
+
+); + +interface DownstreamIssueSectionProps { + endpoints: string[]; +} + +const DownstreamIssueSection: React.ComponentType< + DownstreamIssueSectionProps +> = ({ endpoints }) => ( +
+
+ No trains between {endpoints[0]}{" "} + & {endpoints[1]} +
+
+); +interface RemedySectionProps { + effect: string; + remedy: string | null; + contentTextSize: string; +} +const RemedySection: React.ComponentType = ({ + effect, + remedy, + contentTextSize, +}) => ( +
+ {effect === "shuttle" ? ( + <> +
+ + +
+
+
+ {remedy} +
+
+ All shuttle buses are accessible +
+
+ + ) : ( + <> + +
{remedy}
+ + )} +
+); + +interface MapSectionProps { + disruptionDiagram: DisruptionDiagramData; +} + +const MapSection: React.ComponentType = ({ + disruptionDiagram, +}) => { + + return ( +
+ +
+ ); +}; + +const isMultiLine = (effect: string, region: string) => + effect === "station_closure" && region === "here"; + +const PreFareSingleScreenAlert: React.ComponentType< + PreFareSingleScreenAlertProps +> = (alert) => { + const { + cause, + region, + effect, + endpoints, + issue, + location, + remedy, + remedy_bold, + routes, + unaffected_routes, + updated_at, + disruption_diagram, + } = alert; + + /** + * This switch statement picks the alert layout + * - fallback: icon, followed by a summary & pio text, or just the pio text + * - multiline: icon + route pill + text explaining the lines that are closed at the station + * and then icon + route pill + text explaining normal service. Finally, the map section + * - standard: icon + issue, and icon + remedy, and then map section + * - downstream: map, issue without icon, then icon + remedy + **/ + let layout; + switch (true) { + case effect === "delay": + layout = ( + + ); + break; + case !disruption_diagram: + layout = ( + + ); + break; + case effect === "station_closure" && region === "here": + layout = ( + + ); + break; + case effect === "station_closure": + layout = ( + + ); + break; + case (region === "boundary" || region === "here") && + (effect === "shuttle" || effect === "suspension"): + layout = ( + + ); + break; + case region === "outside" && + endpoints && + (effect === "shuttle" || effect === "suspension"): + layout = ( + + ); + break; + default: + layout = ( + + ); + } + + const showBanner = !isMultiLine(effect, region); + + return ( +
+ {showBanner && } +
+
+
{layout}
+
+ {cause && ( +
+ Cause: {formatCause(cause)} +
+ )} +
+ Updated{" "} + {updated_at} +
+
+
+
+
+ ); +}; + +const getRouteColor = (route_id: string) => { + switch (route_id.substring(0, 3)) { + case "Red": + return "red"; + case "Ora": + return "orange"; + case "Blu": + return "blue"; + case "Gre": + return "green"; + default: + return "yellow"; + } +}; + +// If only one route color is represented ("gl-union" and "gl-riverside" are the same route color) +// use that, otherwise "yellow" +const getAlertColor = (routes: EnrichedRoute[]) => { + const colors = routes.map((r) => getRouteColor(r.route_id)); + const uniqueColors = new Set(colors).size; + return uniqueColors == 1 ? colors[0] : "yellow"; +}; + +const PreFareAlertBanner: React.ComponentType<{ routes: EnrichedRoute[] }> = ({ + routes, +}) => { + let banner; + + if ( + routes.length === 1 && + ["rl", "ol", "bl", "gl", "gl-b", "gl-c", "gl-d", "gl-e"].includes( + routes[0].svg_name + ) + ) { + // One destination, short text + const route = routes[0]; + const LinePill = STRING_TO_SVG[route.svg_name]; + const color = getRouteColor(route.route_id); + + banner = ( +
+ ATTENTION + + riders +
+ ); + } else if (routes.length === 1) { + // One destination, long text + const route = routes[0]; + const LinePill = STRING_TO_SVG[route.svg_name]; + const color = getRouteColor(route.route_id); + + banner = ( +
+ + ATTENTION,{" "} + riders to + + +
+ ); + } else if (routes.length === 2) { + // Two destinations + banner = ( +
+ + ATTENTION,{" "} + riders to + + {routes.map((route) => { + const LinePill = STRING_TO_SVG[route.svg_name]; + return ( + + ); + })} +
+ ); + } else { + // Fallback + banner = ( +
+ + ATTENTION,{" "} + riders + +
+ ); + } + + return banner; +}; + +export default PreFareSingleScreenAlert; diff --git a/assets/src/components/v2/reconstructed_takeover.tsx b/assets/src/components/v2/reconstructed_takeover.tsx index cec1789f1..507d3a6db 100644 --- a/assets/src/components/v2/reconstructed_takeover.tsx +++ b/assets/src/components/v2/reconstructed_takeover.tsx @@ -1,49 +1,70 @@ -import React from "react"; +import React, { useEffect, useRef, useState } from "react"; import { classWithModifiers, imagePath } from "Util/util"; -import FreeText from "./free_text"; +import DisruptionDiagram, { + DisruptionDiagramData, +} from "./disruption_diagram/disruption_diagram"; +import FreeText, { FreeTextType } from "./free_text"; interface ReconAlertProps { issue: string | any; // shouldn't be "any" - location: string; + location: string | FreeTextType; cause: string; remedy: string; routes: any[]; // shouldn't be "any" effect: string; - urgent: boolean; + updated_at: string; + disruption_diagram?: DisruptionDiagramData; } const ReconstructedTakeover: React.ComponentType = (alert) => { - const { cause, effect, issue, location, remedy, routes } = alert; + const { + cause, + effect, + issue, + location, + remedy, + routes, + updated_at, + disruption_diagram, + } = alert; return ( <>
1 ? "yellow" : routes[0].color, ])} >
- -
- {issue.text ? : issue} -
-
- {location} +
+ +
{issue}
+
+ { typeof location === "string" ? location : } +
+ {disruption_diagram && ( +
+ +
+ )}
-
{cause}
- +
+ {cause && `Cause: ${cause}`} +
+
+ Updated {updated_at} +
@@ -51,7 +72,6 @@ const ReconstructedTakeover: React.ComponentType = (alert) => { className={classWithModifiers("alert-container", [ "takeover", "right", - "urgent", routes.length > 1 ? "yellow" : routes[0].color, ])} > @@ -60,24 +80,22 @@ const ReconstructedTakeover: React.ComponentType = (alert) => { {effect === "shuttle" ? ( <> -
{remedy}
+
{remedy}
-
+
All shuttle buses are accessible
) : ( -
- {remedy} -
+
{remedy}
)}
diff --git a/assets/src/util/svg_utils.tsx b/assets/src/util/svg_utils.tsx index ba982c203..cc20bfc8d 100644 --- a/assets/src/util/svg_utils.tsx +++ b/assets/src/util/svg_utils.tsx @@ -1,11 +1,13 @@ import BlueLine from '../../static/images/svgr_bundled/pills/blue-line.svg' import BL from '../../static/images/svgr_bundled/pills/bl.svg' +import GreenLine from '../../static/images/svgr_bundled/pills/green-line.svg' +import GL from '../../static/images/svgr_bundled/pills/gl.svg' import OrangeLine from '../../static/images/svgr_bundled/pills/orange-line.svg' import OL from '../../static/images/svgr_bundled/pills/ol.svg' import RedLine from '../../static/images/svgr_bundled/pills/red-line.svg' import RL from '../../static/images/svgr_bundled/pills/rl.svg' -import GreenLine from '../../static/images/svgr_bundled/pills/green-line.svg' -import GL from '../../static/images/svgr_bundled/pills/gl.svg' +import CommuterRail from '../../static/images/svgr_bundled/pills/commuter-rail.svg' +// GL Branches import GLB from '../../static/images/svgr_bundled/pills/gl-b.svg' import GLC from '../../static/images/svgr_bundled/pills/gl-c.svg' import GLD from '../../static/images/svgr_bundled/pills/gl-d.svg' @@ -18,17 +20,35 @@ import GreenBCircle from '../../static/images/svgr_bundled/pills/green-b-circle. import GreenCCircle from '../../static/images/svgr_bundled/pills/green-c-circle.svg' import GreenDCircle from '../../static/images/svgr_bundled/pills/green-d-circle.svg' import GreenECircle from '../../static/images/svgr_bundled/pills/green-e-circle.svg' -import CommuterRail from '../../static/images/svgr_bundled/pills/commuter-rail.svg' +// Destination pills +import BLBowdoin from '../../static/images/svgr_bundled/pills/bl-bowdoin.svg' +import BLWonderland from '../../static/images/svgr_bundled/pills/bl-wonderland.svg' +import GLCopleyWest from '../../static/images/svgr_bundled/pills/gl-copley-west.svg' +import GLGovtCenter from '../../static/images/svgr_bundled/pills/gl-govt-center.svg' +import GLNorthStationNorth from '../../static/images/svgr_bundled/pills/gl-north-station-north.svg' +import GLBBostonCollege from '../../static/images/svgr_bundled/pills/glb-boston-college.svg' +import GLCClevelandCircle from '../../static/images/svgr_bundled/pills/glc-cleveland-cir.svg' +import GLDRiverside from '../../static/images/svgr_bundled/pills/gld-riverside.svg' +import GLDUnionSq from '../../static/images/svgr_bundled/pills/gld-union-sq.svg' +import GLEHeathSt from '../../static/images/svgr_bundled/pills/gle-heath-st.svg' +import GLEMedfordTufts from '../../static/images/svgr_bundled/pills/gle-medford-tufts.svg' +import OLForestHills from '../../static/images/svgr_bundled/pills/ol-forest-hills.svg' +import OLOakGrove from '../../static/images/svgr_bundled/pills/ol-oak-grove.svg' +import RLAlewife from '../../static/images/svgr_bundled/pills/rl-alewife.svg' +import RLAshmont from '../../static/images/svgr_bundled/pills/rl-ashmont.svg' +import RLBraintree from '../../static/images/svgr_bundled/pills/rl-braintree.svg' export const STRING_TO_SVG: {[key: string]: any} = { "blue-line": BlueLine, "bl": BL, + "green-line": GreenLine, + "gl": GL, "orange-line": OrangeLine, "ol": OL, "red-line": RedLine, "rl": RL, - "green-line": GreenLine, - "gl": GL, + "commuter-rail": CommuterRail, + // Green line branches "gl-b": GLB, "gl-c": GLC, "gl-d": GLD, @@ -41,7 +61,23 @@ export const STRING_TO_SVG: {[key: string]: any} = { "green-c-circle": GreenCCircle, "green-d-circle": GreenDCircle, "green-e-circle": GreenECircle, - "commuter-rail": CommuterRail, + // Pills with destinations + "bl-bowdoin": BLBowdoin, + "bl-wonderland": BLWonderland, + "gl-copley-west": GLCopleyWest, + "gl-govt-center": GLGovtCenter, + "gl-north-station-north": GLNorthStationNorth, + "glb-boston-college": GLBBostonCollege, + "glc-cleveland-cir": GLCClevelandCircle, + "gld-riverside": GLDRiverside, + "gld-union-sq": GLDUnionSq, + "gle-heath-st": GLEHeathSt, + "gle-medford-tufts": GLEMedfordTufts, + "ol-forest-hills": OLForestHills, + "ol-oak-grove": OLOakGrove, + "rl-alewife": RLAlewife, + "rl-ashmont": RLAshmont, + "rl-braintree": RLBraintree }; const STRING_TO_COLOR: {[key: string]: string} = { diff --git a/assets/src/util/util.tsx b/assets/src/util/util.tsx index 4f8292925..30ea09ea3 100644 --- a/assets/src/util/util.tsx +++ b/assets/src/util/util.tsx @@ -50,3 +50,5 @@ export const getScreenSide = (): ScreenSide | null => { }; export const firstWord = (str: string): string => str.split(" ")[0]; + +export const formatCause = (cause: string) => (cause.charAt(0).toUpperCase() + cause.substring(1)).replace("_", " "); \ No newline at end of file diff --git a/assets/static/images/clock-negative.svg b/assets/static/images/clock-negative.svg new file mode 100644 index 000000000..35da92c74 --- /dev/null +++ b/assets/static/images/clock-negative.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/static/images/info.svg b/assets/static/images/info.svg new file mode 100644 index 000000000..2f3f6eb07 --- /dev/null +++ b/assets/static/images/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/static/images/svgr_bundled/bus.svg b/assets/static/images/svgr_bundled/bus.svg new file mode 100644 index 000000000..3e6f37f69 --- /dev/null +++ b/assets/static/images/svgr_bundled/bus.svg @@ -0,0 +1,9 @@ + + + + Icon/Bus 2 + Created with Sketch. + + + + diff --git a/assets/static/images/svgr_bundled/clock-negative.svg b/assets/static/images/svgr_bundled/clock-negative.svg new file mode 100644 index 000000000..93f44fedc --- /dev/null +++ b/assets/static/images/svgr_bundled/clock-negative.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/static/images/svgr_bundled/disruption_diagram/arrow-left-endpoint.svg b/assets/static/images/svgr_bundled/disruption_diagram/arrow-left-endpoint.svg new file mode 100644 index 000000000..8a8a6c2e0 --- /dev/null +++ b/assets/static/images/svgr_bundled/disruption_diagram/arrow-left-endpoint.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/disruption_diagram/arrow-right-endpoint.svg b/assets/static/images/svgr_bundled/disruption_diagram/arrow-right-endpoint.svg new file mode 100644 index 000000000..ebf09863e --- /dev/null +++ b/assets/static/images/svgr_bundled/disruption_diagram/arrow-right-endpoint.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/disruption_diagram/current-stop-diamond.svg b/assets/static/images/svgr_bundled/disruption_diagram/current-stop-diamond.svg new file mode 100644 index 000000000..2ac899f8d --- /dev/null +++ b/assets/static/images/svgr_bundled/disruption_diagram/current-stop-diamond.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/disruption_diagram/current-stop-open-diamond.svg b/assets/static/images/svgr_bundled/disruption_diagram/current-stop-open-diamond.svg new file mode 100644 index 000000000..c5af580b2 --- /dev/null +++ b/assets/static/images/svgr_bundled/disruption_diagram/current-stop-open-diamond.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/disruption_diagram/large-x-octagon-bordered.svg b/assets/static/images/svgr_bundled/disruption_diagram/large-x-octagon-bordered.svg new file mode 100644 index 000000000..04347498b --- /dev/null +++ b/assets/static/images/svgr_bundled/disruption_diagram/large-x-octagon-bordered.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/disruption_diagram/shuttle-emphasis-icon.svg b/assets/static/images/svgr_bundled/disruption_diagram/shuttle-emphasis-icon.svg new file mode 100644 index 000000000..125fdec81 --- /dev/null +++ b/assets/static/images/svgr_bundled/disruption_diagram/shuttle-emphasis-icon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/disruption_diagram/small-x-octagon.svg b/assets/static/images/svgr_bundled/disruption_diagram/small-x-octagon.svg new file mode 100644 index 000000000..2d223d7fc --- /dev/null +++ b/assets/static/images/svgr_bundled/disruption_diagram/small-x-octagon.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/info.svg b/assets/static/images/svgr_bundled/info.svg new file mode 100644 index 000000000..7ea5a9805 --- /dev/null +++ b/assets/static/images/svgr_bundled/info.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/static/images/svgr_bundled/isa.svg b/assets/static/images/svgr_bundled/isa.svg new file mode 100644 index 000000000..145c83531 --- /dev/null +++ b/assets/static/images/svgr_bundled/isa.svg @@ -0,0 +1,10 @@ + + + Icon/Accessible Blue + + + + + + + \ No newline at end of file diff --git a/assets/static/images/svgr_bundled/nearby.svg b/assets/static/images/svgr_bundled/nearby.svg new file mode 100644 index 000000000..efa0c62a6 --- /dev/null +++ b/assets/static/images/svgr_bundled/nearby.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/static/images/svgr_bundled/no-service.svg b/assets/static/images/svgr_bundled/no-service.svg new file mode 100644 index 000000000..c5989ab04 --- /dev/null +++ b/assets/static/images/svgr_bundled/no-service.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/static/images/svgr_bundled/pills/bl-bowdoin.svg b/assets/static/images/svgr_bundled/pills/bl-bowdoin.svg new file mode 100644 index 000000000..10c8b167b --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/bl-bowdoin.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/bl-wonderland.svg b/assets/static/images/svgr_bundled/pills/bl-wonderland.svg new file mode 100644 index 000000000..c9644e569 --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/bl-wonderland.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/gl-copley-west.svg b/assets/static/images/svgr_bundled/pills/gl-copley-west.svg new file mode 100644 index 000000000..d79a30f7e --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/gl-copley-west.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/gl-govt-center.svg b/assets/static/images/svgr_bundled/pills/gl-govt-center.svg new file mode 100644 index 000000000..a6d3e71cb --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/gl-govt-center.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/gl-north-station-north.svg b/assets/static/images/svgr_bundled/pills/gl-north-station-north.svg new file mode 100644 index 000000000..5f55f828f --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/gl-north-station-north.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/glb-boston-college.svg b/assets/static/images/svgr_bundled/pills/glb-boston-college.svg new file mode 100644 index 000000000..9fa0c23fc --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/glb-boston-college.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/glc-cleveland-cir.svg b/assets/static/images/svgr_bundled/pills/glc-cleveland-cir.svg new file mode 100644 index 000000000..6260b690f --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/glc-cleveland-cir.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/gld-riverside.svg b/assets/static/images/svgr_bundled/pills/gld-riverside.svg new file mode 100644 index 000000000..e8977d891 --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/gld-riverside.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/gld-union-sq.svg b/assets/static/images/svgr_bundled/pills/gld-union-sq.svg new file mode 100644 index 000000000..fb2b21085 --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/gld-union-sq.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/gle-heath-st.svg b/assets/static/images/svgr_bundled/pills/gle-heath-st.svg new file mode 100644 index 000000000..8341c5cb1 --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/gle-heath-st.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/gle-medford-tufts.svg b/assets/static/images/svgr_bundled/pills/gle-medford-tufts.svg new file mode 100644 index 000000000..1e25efe02 --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/gle-medford-tufts.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/ol-forest-hills.svg b/assets/static/images/svgr_bundled/pills/ol-forest-hills.svg new file mode 100644 index 000000000..245a5c0de --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/ol-forest-hills.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/ol-oak-grove.svg b/assets/static/images/svgr_bundled/pills/ol-oak-grove.svg new file mode 100644 index 000000000..0f1709712 --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/ol-oak-grove.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/rl-alewife.svg b/assets/static/images/svgr_bundled/pills/rl-alewife.svg new file mode 100644 index 000000000..fe07bdbb2 --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/rl-alewife.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/rl-ashmont.svg b/assets/static/images/svgr_bundled/pills/rl-ashmont.svg new file mode 100644 index 000000000..d9052e556 --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/rl-ashmont.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/static/images/svgr_bundled/pills/rl-braintree.svg b/assets/static/images/svgr_bundled/pills/rl-braintree.svg new file mode 100644 index 000000000..b031e5a1a --- /dev/null +++ b/assets/static/images/svgr_bundled/pills/rl-braintree.svg @@ -0,0 +1,4 @@ + + + + diff --git a/config/config.exs b/config/config.exs index 89a9f6458..c6906e511 100644 --- a/config/config.exs +++ b/config/config.exs @@ -326,12 +326,12 @@ config :screens, # Charles/MGH "place-chmnl" => [ %{informed: "70072", not_informed: "70076", alert_headsign: "Alewife"}, - %{informed: "70075", not_informed: "70071", alert_headsign: "Ashmont/Braintree"} + %{informed: "70075", not_informed: "70071", alert_headsign: "Ashmont & Braintree"} ], # Porter "place-portr" => [ %{informed: "70064", not_informed: "70068", alert_headsign: "Alewife"}, - %{informed: "70067", not_informed: "70063", alert_headsign: "Ashmont/Braintree"} + %{informed: "70067", not_informed: "70063", alert_headsign: "Ashmont & Braintree"} ], "place-welln" => [ %{informed: "70278", not_informed: "70034", alert_headsign: "Forest Hills"}, diff --git a/config/test.exs b/config/test.exs index 8c96437e8..e18b8bcb0 100644 --- a/config/test.exs +++ b/config/test.exs @@ -27,6 +27,73 @@ config :screens, dup_headsign_replacements: %{ "Test 1" => "T1" }, + prefare_alert_headsign_matchers: %{ + # Government Center + "place-gover" => [ + # GL + %{informed: "70203", not_informed: "70200", alert_headsign: "North Station & North"}, + %{ + informed: ~w[70199 70198 70197 70196], + not_informed: "70204", + alert_headsign: "Copley & West" + }, + # BL + %{informed: "70042", not_informed: "70038", alert_headsign: "Wonderland"}, + %{informed: "70038", not_informed: "70041", alert_headsign: "Bowdoin"} + ], + # Tufts + "place-tumnl" => [ + %{informed: "70019", not_informed: "70015", alert_headsign: "Oak Grove"}, + %{informed: "70014", not_informed: "70018", alert_headsign: "Forest Hills"} + ], + # Back Bay + "place-bbsta" => [ + %{informed: "70017", not_informed: "70013", alert_headsign: "Oak Grove"}, + %{informed: "70012", not_informed: "70016", alert_headsign: "Forest Hills"} + ], + # Forest Hills + "place-forhl" => [ + %{informed: "70003", not_informed: nil, alert_headsign: "Oak Grove"} + ], + # Maverick + "place-mvbcl" => [ + %{informed: "70048", not_informed: "70044", alert_headsign: "Wonderland"}, + %{informed: "70043", not_informed: "70047", alert_headsign: "Bowdoin"} + ], + # Ashmont + "place-asmnl" => [ + %{informed: "70092", not_informed: nil, alert_headsign: "Alewife"} + ], + # Charles/MGH + "place-chmnl" => [ + %{informed: "70072", not_informed: "70076", alert_headsign: "Alewife"}, + %{informed: "70075", not_informed: "70071", alert_headsign: "Ashmont & Braintree"} + ], + # Porter + "place-portr" => [ + %{informed: "70064", not_informed: "70068", alert_headsign: "Alewife"}, + %{informed: "70067", not_informed: "70063", alert_headsign: "Ashmont & Braintree"} + ], + # Wellington + "place-welln" => [ + %{informed: "70278", not_informed: "70034", alert_headsign: "Forest Hills"}, + %{informed: "70035", not_informed: "70279", alert_headsign: "Oak Grove"} + ], + # Downtown Crossing + "place-dwnxg" => [ + # OL + %{informed: "70018", not_informed: "70022", alert_headsign: "Forest Hills"}, + %{informed: "70023", not_informed: "70019", alert_headsign: "Oak Grove"}, + # RL + %{informed: "70076", not_informed: "70080", alert_headsign: "Alewife"}, + %{informed: "70079", not_informed: "70075", alert_headsign: "Ashmont & Braintree"} + ], + # Malden Center + "place-mlmnl" => [ + %{informed: "70032", not_informed: "70036", alert_headsign: "Forest Hills"}, + %{informed: "70036", not_informed: "70033", alert_headsign: "Oak Grove"} + ] + }, dup_alert_headsign_matchers: %{ "place-B" => [ %{ diff --git a/lib/screens/alerts/alert.ex b/lib/screens/alerts/alert.ex index cd0c8bed7..f5e4e815b 100644 --- a/lib/screens/alerts/alert.ex +++ b/lib/screens/alerts/alert.ex @@ -97,9 +97,14 @@ defmodule Screens.Alerts.Alert do | :unknown @type informed_entity :: %{ + optional(:facility) => %{ + id: String.t() | nil, + name: String.t() | nil + }, stop: String.t() | nil, route: String.t() | nil, - route_type: non_neg_integer() | nil + route_type: non_neg_integer() | nil, + direction_id: 0 | 1 | nil } @type t :: %__MODULE__{ @@ -571,5 +576,18 @@ defmodule Screens.Alerts.Alert do informed_entities end + @doc "Returns IDs of all subway routes affected by the alert. Green Line routes are not consolidated." + def informed_subway_routes(%__MODULE__{} = alert) do + informed_route_ids = MapSet.new(alert.informed_entities, & &1.route) + + Enum.filter( + ["Blue", "Orange", "Red", "Green-B", "Green-C", "Green-D", "Green-E"], + &(&1 in informed_route_ids) + ) + end + def effect(%__MODULE__{effect: effect}), do: effect + + def direction_id(%__MODULE__{informed_entities: informed_entities}), + do: List.first(informed_entities).direction_id end diff --git a/lib/screens/alerts/informed_entity.ex b/lib/screens/alerts/informed_entity.ex new file mode 100644 index 000000000..3b7ae47ea --- /dev/null +++ b/lib/screens/alerts/informed_entity.ex @@ -0,0 +1,32 @@ +defmodule Screens.Alerts.InformedEntity do + @moduledoc """ + Functions to query alert informed entities. + """ + + alias Screens.Alerts.Alert + + @type t :: Alert.informed_entity() + + @spec whole_route?(t()) :: boolean + def whole_route?(ie) do + match?( + %{route: route_id, direction_id: nil, stop: nil} + when not is_nil(route_id), + ie + ) + end + + @spec whole_direction?(t()) :: boolean + def whole_direction?(ie) do + match?( + %{route: route_id, direction_id: direction_id, stop: nil} + when not is_nil(route_id) and not is_nil(direction_id), + ie + ) + end + + @spec parent_station?(t()) :: boolean + def parent_station?(ie) do + match?(%{stop: "place-" <> _}, ie) + end +end diff --git a/lib/screens/alerts/parser.ex b/lib/screens/alerts/parser.ex index e03956119..72d104227 100644 --- a/lib/screens/alerts/parser.ex +++ b/lib/screens/alerts/parser.ex @@ -84,13 +84,9 @@ defmodule Screens.Alerts.Parser do :error -> nil end - %{ - stop: get_in(ie, ["stop"]), - route: get_in(ie, ["route"]), - route_type: get_in(ie, ["route_type"]), - direction_id: get_in(ie, ["direction_id"]), - facility: %{id: facility_id, name: facility_name} - } + ie + |> parse_informed_entity() + |> Map.put(:facility, %{id: facility_id, name: facility_name}) end defp parse_and_sort_active_periods(periods) do diff --git a/lib/screens/jason_tuple_encoder.ex b/lib/screens/jason_tuple_encoder.ex new file mode 100644 index 000000000..072c9cedf --- /dev/null +++ b/lib/screens/jason_tuple_encoder.ex @@ -0,0 +1,7 @@ +defimpl Jason.Encoder, for: Tuple do + def encode(data, options) when is_tuple(data) do + data + |> Tuple.to_list() + |> Jason.Encoder.List.encode(options) + end +end diff --git a/lib/screens/route_patterns/route_pattern.ex b/lib/screens/route_patterns/route_pattern.ex index 301aa2c18..57f856afe 100644 --- a/lib/screens/route_patterns/route_pattern.ex +++ b/lib/screens/route_patterns/route_pattern.ex @@ -40,10 +40,12 @@ defmodule Screens.RoutePatterns.RoutePattern do end @doc """ - Fetches stop sequences for all routes serving stop in all applicable directions. + Returns a map from route ID to a list of stop sequences of that route, for all + routes serving stop, in all applicable directions. """ - @spec fetch_stop_sequences_through_stop(Stop.id()) :: {:ok, list(list(Stop.id()))} | :error - def fetch_stop_sequences_through_stop( + @spec fetch_tagged_stop_sequences_through_stop(Stop.id()) :: + {:ok, %{Route.id() => list(list(Stop.id()))}} | :error + def fetch_tagged_stop_sequences_through_stop( stop_id, route_filters \\ [], get_json_fn \\ &V3Api.get_json/2 @@ -60,7 +62,7 @@ defmodule Screens.RoutePatterns.RoutePattern do case get_json_fn.("route_patterns", params) do {:ok, result} -> - {:ok, get_stop_sequences_from_result(result)} + {:ok, get_tagged_stop_sequences_from_result(result)} _ -> :error @@ -68,16 +70,26 @@ defmodule Screens.RoutePatterns.RoutePattern do end @doc """ - Gets stop sequences for stop and converts it to a list of parent station IDs. + Returns a map from route ID to a list of stop sequences of that route. Stop sequences + are described in terms of parent station IDs, not platform IDs. + + Pass `true` for `canonical_only?` to limit results to canonical route patterns. + With `canonical_only? = true`, + - For most routes (everything but Red Line), only one stop sequence will be in the list. + - For Red Line, the list will contain one stop sequence for the Ashmont branch and one for the Braintree branch. + + Pass `false` for `canonical_only?` to limit results to *non-canonical* route patterns. (You probably don't want to do this!) + If no parent station data exists, platform_id is returned instead. - Only stop sequences for one direction of travel is returned. + Only stop sequences for direction ID 0 are returned. Assumes that all stop sequences in result are platforms. """ - @spec fetch_parent_station_sequences_through_stop(Stop.id(), list(String.t())) :: - {:ok, list(list(Stop.id()))} | :error - def fetch_parent_station_sequences_through_stop( + @spec fetch_tagged_parent_station_sequences_through_stop(Stop.id(), list(String.t())) :: + {:ok, %{Route.id() => list(list(Stop.id()))}} | :error + def fetch_tagged_parent_station_sequences_through_stop( stop_id, route_filters, + canonical_only? \\ nil, get_json_fn \\ &V3Api.get_json/2 ) do params = %{ @@ -87,28 +99,47 @@ defmodule Screens.RoutePatterns.RoutePattern do "filter[route]" => Enum.join(route_filters, ",") } + params = + if is_boolean(canonical_only?), + do: Map.put(params, "filter[canonical]", canonical_only?), + else: params + case get_json_fn.("route_patterns", params) do {:ok, %{"data" => []}} -> :error {:ok, result} -> - {:ok, convert_platform_to_parent_station(result)} + {:ok, get_tagged_parent_station_sequences_from_result(result)} _ -> :error end end - defp get_stop_sequences_from_result(result) do - get_in(result, [ - "included", - Access.filter(&(&1["type"] == "trip")), - "relationships", - "stops", - "data", - Access.all(), - "id" - ]) + @doc """ + Given a map from route ID to stop sequences of that route, returns a flat list + of all of the stop sequences. + + ``` + iex> untag_stop_sequences(%{"route1" => [sequence1, sequence2], "route2" => [sequence3]}) + [sequence1, sequence2, sequence3] + ``` + """ + @spec untag_stop_sequences(%{Route.id() => list(list(Stop.id()))}) :: list(list(Stop.id())) + def untag_stop_sequences(tagged_stop_sequences) do + Enum.flat_map(tagged_stop_sequences, &elem(&1, 1)) + end + + defp get_tagged_stop_sequences_from_result(result) do + result["included"] + |> Enum.filter(&(&1["type"] == "trip")) + |> Enum.map(fn trip -> + route = trip["relationships"]["route"]["data"]["id"] + sequence = Enum.map(trip["relationships"]["stops"]["data"], & &1["id"]) + + {route, sequence} + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) end defp get_platform_to_station_map_from_result(result) do @@ -130,15 +161,23 @@ defmodule Screens.RoutePatterns.RoutePattern do |> Enum.into(%{}) end - defp convert_platform_to_parent_station(result) do + defp get_tagged_parent_station_sequences_from_result(result) do platform_to_station_map = get_platform_to_station_map_from_result(result) result - |> get_stop_sequences_from_result() - |> Enum.map(fn stop_sequence -> - Enum.map(stop_sequence, &Map.fetch!(platform_to_station_map, &1)) + |> get_tagged_stop_sequences_from_result() + |> Map.new(fn {route_id, stop_sequences} -> + station_sequences = + stop_sequences + |> Enum.map(&platforms_to_stations(&1, platform_to_station_map)) + # Dedup the stop sequences (both directions are listed, but we only need 1) + |> Enum.uniq_by(&MapSet.new/1) + + {route_id, station_sequences} end) - # Dedup the stop sequences (both directions are listed, but we only need 1) - |> Enum.uniq_by(&MapSet.new/1) + end + + defp platforms_to_stations(stop_sequence, platform_to_station_map) do + Enum.map(stop_sequence, &Map.fetch!(platform_to_station_map, &1)) end end diff --git a/lib/screens/stops/stop.ex b/lib/screens/stops/stop.ex index 98d6ff8f1..11083e79a 100644 --- a/lib/screens/stops/stop.ex +++ b/lib/screens/stops/stop.ex @@ -4,7 +4,7 @@ defmodule Screens.Stops.Stop do For a while, all stop-related data was fetched from the API, until we needed to provide consistent abbreviations in the reconstructed alert. Now it's valuable to have a local copy of these stop sequences. A lot of our code still collects these sequences from the API, though, whether in functions here - or in functions in `route_pattern.ex` (see fetch_parent_station_sequences_through_stop). + or in functions in `route_pattern.ex` (see fetch_tagged_parent_station_sequences_through_stop). So there's inconsistent use of this local data. """ @@ -14,6 +14,7 @@ defmodule Screens.Stops.Stop do alias Screens.RoutePatterns.RoutePattern alias Screens.Routes alias Screens.Routes.Route + alias Screens.RouteType alias Screens.Stops.StationsWithRoutesAgent alias Screens.Util alias Screens.V3Api @@ -208,8 +209,6 @@ defmodule Screens.Stops.Stop do ] @green_line_trunk_stops [ - # These 3 eventually will NOT be trunk stops, but are until Medford opens - {"place-unsqu", {"Union Square", "Union Sq"}}, {"place-lech", {"Lechmere", "Lechmere"}}, {"place-spmnl", {"Science Park/West End", "Science Pk"}}, {"place-north", {"North Station", "North Sta"}}, @@ -223,6 +222,18 @@ defmodule Screens.Stops.Stop do {"place-kencl", {"Kenmore", "Kenmore"}} ] + @medford_tufts_branch_stops [ + {"place-mdftf", {"Medford / Tufts", "Medford"}}, + {"place-balsq", {"Ball Square", "Ball Sq"}}, + {"place-mgngl", {"Magoun Square", "Magoun Sq"}}, + {"place-gilmn", {"Gilman Square", "Gilman Sq"}}, + {"place-esomr", {"East Somerville", "E Somerville"}} + ] + + @union_square_branch_stops [ + {"place-unsqu", {"Union Square", "Union Sq"}} + ] + @route_stop_sequences %{ "Blue" => [@blue_line_stops], "Orange" => [@orange_line_stops], @@ -424,6 +435,19 @@ defmodule Screens.Stops.Stop do Enum.map(@green_line_branches, &get_route_stop_sequence/1) end + @doc """ + Returns an unordered MapSet of all GL stops west of Copley. + """ + @spec get_gl_stops_west_of_copley() :: MapSet.t(id()) + def get_gl_stops_west_of_copley do + get_gl_stop_sequences() + |> Enum.flat_map(fn stop_sequence -> + [_copley | west_of_copley] = Enum.drop_while(stop_sequence, &(&1 != "place-coecl")) + west_of_copley + end) + |> MapSet.new() + end + defp sequence_match?(stop_sequence, informed_entities) do ie_stops = informed_entities @@ -443,6 +467,10 @@ defmodule Screens.Stops.Stop do @green_line_trunk_stops end + def rl_trunk_stops do + @red_line_trunk_stops + end + def stop_id_to_name(route_id) do @route_stop_sequences |> Map.get(route_id) @@ -462,19 +490,16 @@ defmodule Screens.Stops.Stop do def fetch_location_context(app, stop_id, now) do with alert_route_types <- get_route_type_filter(app, stop_id), {:ok, routes_at_stop} <- Route.fetch_routes_by_stop(stop_id, now, alert_route_types), - route_ids <- Route.route_ids(routes_at_stop), - {:ok, stop_sequences} <- - (cond do - app in [BusEink, BusShelter, GlEink] -> - RoutePattern.fetch_stop_sequences_through_stop(stop_id) - - app in [PreFare, Dup, Triptych] -> - RoutePattern.fetch_parent_station_sequences_through_stop(stop_id, route_ids) - end) do + {:ok, tagged_stop_sequences} <- + fetch_tagged_stop_sequences_by_app(app, stop_id, routes_at_stop) do + stop_name = fetch_stop_name(stop_id) + stop_sequences = RoutePattern.untag_stop_sequences(tagged_stop_sequences) + {:ok, %LocationContext{ home_stop: stop_id, - stop_sequences: stop_sequences, + home_stop_name: stop_name, + tagged_stop_sequences: tagged_stop_sequences, upstream_stops: upstream_stop_id_set(stop_id, stop_sequences), downstream_stops: downstream_stop_id_set(stop_id, stop_sequences), routes: routes_at_stop, @@ -492,7 +517,7 @@ defmodule Screens.Stops.Stop do # Returns the route types we care about for the alerts of this screen type / place @spec get_route_type_filter(screen_type(), String.t()) :: - list(atom()) + list(RouteType.t()) def get_route_type_filter(app, _) when app in [BusEink, BusShelter], do: [:bus] def get_route_type_filter(GlEink, _), do: [:light_rail] # Ashmont should not show Mattapan alerts for PreFare or Dup @@ -516,4 +541,35 @@ defmodule Screens.Stops.Stop do |> Enum.flat_map(fn stop_sequence -> Util.slice_after(stop_sequence, stop_id) end) |> MapSet.new() end + + def on_glx?(stop_id) do + stop_id in Enum.map(@medford_tufts_branch_stops ++ @union_square_branch_stops, &elem(&1, 0)) + end + + def on_ashmont_branch?(stop_id) do + stop_id in Enum.map(@red_line_ashmont_branch_stops, &elem(&1, 0)) + end + + def on_braintree_branch?(stop_id) do + stop_id in Enum.map(@red_line_braintree_branch_stops, &elem(&1, 0)) + end + + defp fetch_tagged_stop_sequences_by_app(app, stop_id, _routes_at_stop) + when app in [BusEink, BusShelter, GlEink] do + RoutePattern.fetch_tagged_stop_sequences_through_stop(stop_id) + end + + defp fetch_tagged_stop_sequences_by_app(app, stop_id, routes_at_stop) + when app in [Dup, Triptych] do + route_ids = Route.route_ids(routes_at_stop) + RoutePattern.fetch_tagged_parent_station_sequences_through_stop(stop_id, route_ids) + end + + defp fetch_tagged_stop_sequences_by_app(app, stop_id, routes_at_stop) + when app == PreFare do + route_ids = Route.route_ids(routes_at_stop) + + # We limit results to canonical route patterns only--no stop sequences for nonstandard patterns. + RoutePattern.fetch_tagged_parent_station_sequences_through_stop(stop_id, route_ids, true) + end end diff --git a/lib/screens/util.ex b/lib/screens/util.ex index a029d0bc4..c24ce4fa6 100644 --- a/lib/screens/util.ex +++ b/lib/screens/util.ex @@ -131,11 +131,24 @@ defmodule Screens.Util do """ @spec format_name_list_to_string([String.t()]) :: String.t() def format_name_list_to_string([string]), do: "#{string}" - def format_name_list_to_string([s1, s2]), do: "#{s1} and #{s2}" + def format_name_list_to_string([s1, s2]), do: "#{s1} & #{s2}" def format_name_list_to_string(list) do list - |> List.update_at(length(list) - 1, &"and #{&1}") + |> List.update_at(length(list) - 1, &"& #{&1}") + |> Enum.join(", ") + end + + @doc """ + Same as regular string list formatter, but for audio (extra comma for clarity, "and" instead of "&") + """ + @spec format_name_list_to_string_audio([String.t()]) :: String.t() + def format_name_list_to_string_audio([string]), do: "#{string}" + def format_name_list_to_string_audio([s1, s2]), do: "#{s1}, and, #{s2}" + + def format_name_list_to_string_audio(list) do + list + |> List.update_at(length(list) - 1, &"and, #{&1}") |> Enum.join(", ") end diff --git a/lib/screens/v2/candidate_generator/widgets/alerts.ex b/lib/screens/v2/candidate_generator/widgets/alerts.ex index 49b98eec2..1a861778b 100644 --- a/lib/screens/v2/candidate_generator/widgets/alerts.ex +++ b/lib/screens/v2/candidate_generator/widgets/alerts.ex @@ -2,6 +2,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.Alerts do @moduledoc false alias Screens.Alerts.Alert + alias Screens.LocationContext alias Screens.Routes.Route alias Screens.Stops.Stop alias Screens.Util @@ -24,8 +25,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.Alerts do ) when app in @alert_supporting_screen_types do with {:ok, location_context} <- fetch_location_context_fn.(app, stop_id, now), - reachable_stop_ids = - local_and_downstream_stop_ids(location_context.stop_sequences, stop_id), + reachable_stop_ids = local_and_downstream_stop_ids(location_context, stop_id), route_ids <- Route.route_ids(location_context.routes), {:ok, alerts} <- fetch_alerts_by_stop_and_route_fn.( @@ -84,13 +84,15 @@ defmodule Screens.V2.CandidateGenerator.Widgets.Alerts do |> Enum.to_list() end - defp local_and_downstream_stop_ids(nil, home_stop) do + defp local_and_downstream_stop_ids(location_context, home_stop) + when location_context.tagged_stop_sequences == nil do [home_stop] end - defp local_and_downstream_stop_ids(stop_sequences, home_stop) do + defp local_and_downstream_stop_ids(location_context, home_stop) do downstream_stop_ids = - stop_sequences + location_context + |> LocationContext.stop_sequences() |> Enum.flat_map(&Util.slice_after(&1, home_stop)) |> Enum.uniq() diff --git a/lib/screens/v2/candidate_generator/widgets/reconstructed_alert.ex b/lib/screens/v2/candidate_generator/widgets/reconstructed_alert.ex index 478c7ae5b..2759b86a5 100644 --- a/lib/screens/v2/candidate_generator/widgets/reconstructed_alert.ex +++ b/lib/screens/v2/candidate_generator/widgets/reconstructed_alert.ex @@ -2,9 +2,9 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do @moduledoc false alias Screens.Alerts.Alert + alias Screens.LocationContext, as: LC alias Screens.Routes.Route alias Screens.Stops.Stop - alias Screens.Util alias Screens.V2.LocalizedAlert alias Screens.V2.WidgetInstance.ReconstructedAlert alias ScreensConfig.Screen @@ -13,6 +13,37 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do @relevant_effects ~w[shuttle suspension station_closure delay]a + @gl_eastbound_split_stops [ + "place-mdftf", + "place-balsq", + "place-mgngl", + "place-gilmn", + "place-esomr", + "place-unsqu", + "place-lech" + ] + + @gl_trunk_stop_ids [ + "place-unsqu", + "place-lech", + "place-spmnl", + "place-north", + "place-haecl", + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl", + "place-coecl", + "place-hymnl", + "place-kencl" + ] + + @default_distance 99 + + @type stop_id :: String.t() + @type distance :: non_neg_integer() + @type home_stop_distance_map :: %{stop_id() => distance()} + @doc """ Given the stop_id defined in the header, determine relevant routes Given the routes, fetch all alerts for the route @@ -26,35 +57,178 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do fetch_stop_name_fn \\ &Stop.fetch_stop_name/1, fetch_location_context_fn \\ &Stop.fetch_location_context/3 ) do + # Filtering by subway and light_rail types with {:ok, location_context} <- fetch_location_context_fn.(PreFare, stop_id, now), route_ids <- Route.route_ids(location_context.routes), {:ok, alerts} <- fetch_alerts_fn.(route_ids: route_ids) do - alerts - |> relevant_alerts(config, location_context, now) - |> Enum.map(fn alert -> - %ReconstructedAlert{ - screen: config, - alert: alert, - now: now, - location_context: location_context, - informed_stations_string: get_stations(alert, fetch_stop_name_fn), - is_terminal_station: is_terminal?(stop_id, location_context.stop_sequences) - } - end) + relevant_alerts = relevant_alerts(alerts, location_context, now) + is_terminal_station = is_terminal?(stop_id, LC.stop_sequences(location_context)) + + immediate_disruptions = get_immediate_disruptions(relevant_alerts, location_context) + downstream_disruptions = get_downstream_disruptions(relevant_alerts, location_context) + moderate_delays = get_moderate_disruptions(relevant_alerts) + + common_parameters = [ + config: config, + location_context: location_context, + fetch_stop_name_fn: fetch_stop_name_fn, + is_terminal_station: is_terminal_station, + now: now + ] + + cond do + Enum.any?(immediate_disruptions) -> + create_alert_instances( + immediate_disruptions, + true, + common_parameters + ) ++ + create_alert_instances(downstream_disruptions, false, common_parameters) ++ + create_alert_instances(moderate_delays, false, common_parameters) + + Enum.any?(downstream_disruptions) -> + fullscreen_alerts = + find_closest_downstream_alerts( + downstream_disruptions, + stop_id, + LC.stop_sequences(location_context) + ) + + flex_zone_alerts = downstream_disruptions -- fullscreen_alerts + + create_alert_instances(fullscreen_alerts, true, common_parameters) ++ + create_alert_instances(flex_zone_alerts, false, common_parameters) ++ + create_alert_instances(moderate_delays, false, common_parameters) + + true -> + create_alert_instances(moderate_delays, true, common_parameters) + end else :error -> [] end end - defp relevant_alerts(alerts, config, location_context, now) do - Enum.filter(alerts, fn %Alert{effect: effect} = alert -> - reconstructed_alert = %ReconstructedAlert{ + defp get_immediate_disruptions(relevant_alerts, location_context) do + Enum.filter( + relevant_alerts, + fn + %{effect: :delay} -> + false + + alert -> + LocalizedAlert.location(%{alert: alert, location_context: location_context}) in [ + :inside, + :boundary_upstream, + :boundary_downstream + ] + end + ) + end + + defp get_downstream_disruptions(relevant_alerts, location_context) do + Enum.filter( + relevant_alerts, + fn + %{effect: :delay} = alert -> + get_severity_level(alert.severity) == :severe + + alert -> + LocalizedAlert.location(%{alert: alert, location_context: location_context}) in [ + :downstream, + :upstream + ] + end + ) + end + + defp get_moderate_disruptions(relevant_alerts) do + Enum.filter( + relevant_alerts, + &(&1.effect == :delay and get_severity_level(&1.severity) == :moderate) + ) + end + + defp create_alert_instances( + alerts, + is_full_screen, + config: config, + location_context: location_context, + fetch_stop_name_fn: fetch_stop_name_fn, + is_terminal_station: is_terminal_station, + now: now + ) do + Enum.map(alerts, fn alert -> + %ReconstructedAlert{ screen: config, alert: alert, - location_context: location_context, now: now, - informed_stations_string: "A Station" + location_context: location_context, + informed_stations: get_stations(alert, fetch_stop_name_fn), + is_terminal_station: is_terminal_station, + is_full_screen: is_full_screen } + end) + end + + defp find_closest_downstream_alerts(alerts, stop_id, stop_sequences) do + home_stop_distance_map = build_distance_map(stop_id, stop_sequences) + # Map each alert with its distance from home. + alerts + |> Enum.map(fn %{informed_entities: ies} = alert -> + distance = + ies + |> Enum.filter(fn + # Alert affects entire line + %{stop: nil, route: route} -> is_binary(route) + ie -> String.starts_with?(ie.stop, "place-") + end) + |> Enum.map(&get_distance(stop_id, home_stop_distance_map, &1)) + |> Enum.min() + + {alert, distance} + end) + |> Enum.group_by(&elem(&1, 1), &elem(&1, 0)) + |> Enum.sort_by(&elem(&1, 0)) + # The first item will be all alerts with the shortest distance. + |> List.first() + |> elem(1) + end + + defp build_distance_map(home_stop_id, stop_sequences) do + Enum.reduce(stop_sequences, %{}, fn stop_sequence, distances_by_stop -> + stop_sequence + # Index each element by its distance from home_stop_id. For example if home_stop_id is at position 2, then indices would start at -2. + |> Enum.with_index(-Enum.find_index(stop_sequence, &(&1 == home_stop_id))) + # Convert negative distances to positive, and put into a map. + |> Map.new(fn {stop, d} -> {stop, abs(d)} end) + # Merge with the distances recorded from previous stop sequences. + # If a stop already has a distance recorded, the distances should be the same. Use the first one. + |> Map.merge(distances_by_stop, fn _stop, d1, _d2 -> d1 end) + end) + end + + # Default to 99 if stop_id is not in distance map. + # Stops will not be present in the map if informed_entity and home stop are on different branches. + # i.e. Braintree is not present in Ashmont stop_sequences, but is still a relevant alert. + @spec get_distance(stop_id(), home_stop_distance_map(), Alert.informed_entity()) :: distance() + defp get_distance(home_stop_id, home_stop_distance_map, informed_entity) + + defp get_distance(_home_stop_id, _home_stop_distance_map, %{stop: nil}), do: 0 + + defp get_distance(home_stop_id, home_stop_distance_map, %{route: "Green" <> _, stop: ie_stop_id}) + when home_stop_id in @gl_trunk_stop_ids and ie_stop_id in @gl_eastbound_split_stops, + do: Map.get(home_stop_distance_map, "place-lech", @default_distance) + + defp get_distance(home_stop_id, home_stop_distance_map, %{route: "Green" <> _, stop: ie_stop_id}) + when home_stop_id in @gl_trunk_stop_ids and ie_stop_id not in @gl_trunk_stop_ids, + do: Map.get(home_stop_distance_map, "place-kencl", @default_distance) + + defp get_distance(_, home_stop_distance_map, %{stop: stop_id}), + do: Map.get(home_stop_distance_map, stop_id, @default_distance) + + defp relevant_alerts(alerts, location_context, now) do + Enum.filter(alerts, fn %Alert{effect: effect} = alert -> + reconstructed_alert = %{alert: alert, location_context: location_context} relevant_effect?(effect) and relevant_location?(reconstructed_alert) and Alert.happening_now?(alert, now) @@ -81,38 +255,27 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do end end - defp relevant_inside_alert?( - %ReconstructedAlert{alert: %Alert{effect: :delay}} = reconstructed_alert - ), - do: relevant_delay?(reconstructed_alert) + defp relevant_inside_alert?(%{alert: %Alert{effect: :delay}} = reconstructed_alert), + do: relevant_delay?(reconstructed_alert) defp relevant_inside_alert?(_), do: true - defp relevant_boundary_alert?(%ReconstructedAlert{alert: %Alert{effect: :station_closure}}), + defp relevant_boundary_alert?(%{alert: %Alert{effect: :station_closure}}), do: false - defp relevant_boundary_alert?( - %ReconstructedAlert{ - alert: %Alert{effect: :delay} - } = reconstructed_alert - ), - do: relevant_delay?(reconstructed_alert) + defp relevant_boundary_alert?(%{alert: %Alert{effect: :delay}} = reconstructed_alert), + do: relevant_delay?(reconstructed_alert) defp relevant_boundary_alert?(_), do: true - defp relevant_delay?( - %ReconstructedAlert{alert: %Alert{severity: severity}} = reconstructed_alert - ) do - severity > 3 and relevant_direction?(reconstructed_alert) + defp relevant_delay?(%{alert: %Alert{severity: severity}} = reconstructed_alert) do + get_severity_level(severity) != :low and relevant_direction?(reconstructed_alert) end - # This function assumes that stop_sequences is ordered by direction north/east -> south/west. # If the current station's stop_id is the first or last entry in all stop_sequences, # it is a terminal station. Delay alerts heading in the direction of the station are not relevant. - defp relevant_direction?(%ReconstructedAlert{ - alert: alert, - location_context: %{home_stop: stop_id, stop_sequences: stop_sequences} - }) do + defp relevant_direction?(%{alert: alert, location_context: location_context}) do + stop_sequences = LC.stop_sequences(location_context) informed_entities = Alert.informed_entities(alert) direction_id = @@ -129,14 +292,14 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do # North/East side terminal stations Enum.all?( stop_sequences, - fn stop_sequence -> stop_id == List.first(stop_sequence) end + fn stop_sequence -> location_context.home_stop == List.first(stop_sequence) end ) -> 0 # South/West side terminal stations Enum.all?( stop_sequences, - fn stop_sequence -> stop_id == List.last(stop_sequence) end + fn stop_sequence -> location_context.home_stop == List.last(stop_sequence) end ) -> 1 @@ -148,7 +311,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do relevant_direction_for_terminal == nil or relevant_direction_for_terminal == direction_id end - defp get_stations(alert, fetch_stop_name_fn) do + defp get_stations(%{effect: :station_closure} = alert, fetch_stop_name_fn) do stop_ids = alert |> Alert.informed_entities() @@ -161,7 +324,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do case stop_ids do [] -> - nil + [] _ -> stop_ids @@ -170,13 +333,15 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do |> Enum.flat_map( &case fetch_stop_name_fn.(&1) do :error -> [] + "Massachusetts Avenue" -> ["Mass Ave"] name -> [name] end ) - |> Util.format_name_list_to_string() end end + defp get_stations(_alert, _fetch_stop_name_fn), do: [] + defp is_terminal?(stop_id, stop_sequences) do # Can't use Enum.any, because then Govt Center will be seen as a terminal # Using all is ok because no station is the terminal of one line and NOT the terminal of another line @@ -185,4 +350,12 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlert do List.first(stop_sequence) == stop_id or List.last(stop_sequence) == stop_id end) end + + defp get_severity_level(severity) do + cond do + severity < 5 -> :low + severity < 7 -> :moderate + true -> :severe + end + end end diff --git a/lib/screens/v2/disruption_diagram.ex b/lib/screens/v2/disruption_diagram.ex new file mode 100644 index 000000000..86c0c3a23 --- /dev/null +++ b/lib/screens/v2/disruption_diagram.ex @@ -0,0 +1,87 @@ +defmodule Screens.V2.DisruptionDiagram do + @moduledoc """ + Public interface for generating disruption diagrams. + """ + + alias Screens.V2.DisruptionDiagram.Model + alias Screens.V2.LocalizedAlert + + # We don't need to define any new struct for the diagram's source data-- + # we can use any map/struct that satisfies LocalizedAlert.t(). + @type t :: LocalizedAlert.t() + + @type serialized_response :: continuous_disruption_diagram() | discrete_disruption_diagram() + + @type continuous_disruption_diagram :: %{ + effect: :shuttle | :suspension, + # A 2-element list, giving indices of the effect region's *boundary stops*, inclusive. + # For example in this scenario: + # 0 1 2 3 4 5 6 7 8 + # <= === O ========= O - - X - - X - - X - - O === O + # |---------range---------| + # The range is [3, 7]. + # + # SPECIAL CASE: + # If the range starts at 0 or ends at the last element of the array, + # then the symbol for that terminal stop should use the appropriate + # disruption symbol, not the "normal service" symbol. + # For example if the range is [0, 5], the left end of the + # diagram should use a disruption symbol: + # 0 1 2 3 4 5 6 7 8 + # X - - X - - X - - X - - X - - O ========= O === => + # |------------range------------| + effect_region_slot_index_range: {non_neg_integer(), non_neg_integer()}, + line: line(), + current_station_slot_index: non_neg_integer(), + # First and last elements of the list are `end_slot`s, middle elements are `middle_slot`s. + slots: list(slot()) + } + + @type discrete_disruption_diagram :: %{ + effect: :station_closure, + closed_station_slot_indices: list(non_neg_integer()), + line: line(), + current_station_slot_index: non_neg_integer(), + # First and last elements of the list are `end_slot`s, middle elements are `middle_slot`s. + slots: list(slot()) + } + + @type slot :: end_slot() | middle_slot() + + @type end_slot :: %{ + type: :arrow | :terminal, + label_id: end_label_id() + } + + @type middle_slot :: %{ + label: label(), + show_symbol: boolean() + } + + @type label :: label_map() | ellipsis() + + @type label_map :: %{full: String.t(), abbrev: String.t()} + + # Literally the string "…", but you can't use string literals as types in elixir + @type ellipsis :: String.t() + + # End labels have hardcoded presentation, so we just send an ID for the client to use in + # a lookup. + # + # In most cases, the IDs are parent station IDs. For compound labels like + # "to Ashmont & Braintree", two IDs are joined with '+': "place-asmnl+place-brntn". + # For labels that don't use station names, we just use an agreed-upon string: + # "western_branches", "place-kencl+west", etc. + # + # The rest of the labels' presentations are computed based on the height of the end labels, + # so we can send actual text for those--it will be dynamically resized to fit. + @type end_label_id :: String.t() + + @type line :: :blue | :orange | :red | :green + + @type branch :: :b | :c | :d | :e | :ashmont | :braintree | :trunk + + @doc "Produces a JSON-serializable map representing the disruption diagram." + @spec serialize(t()) :: {:ok, serialized_response()} | {:error, reason :: String.t()} + defdelegate serialize(localized_alert), to: Model +end diff --git a/lib/screens/v2/disruption_diagram/builder.ex b/lib/screens/v2/disruption_diagram/builder.ex new file mode 100644 index 000000000..d1086ad80 --- /dev/null +++ b/lib/screens/v2/disruption_diagram/builder.ex @@ -0,0 +1,1007 @@ +defmodule Screens.V2.DisruptionDiagram.Builder do + @moduledoc """ + An intermediate data structure for transforming a localized alert to a disruption diagram. + + Values should be accessed/manipulated only via public module functions. + """ + + alias Aja.Vector + alias Screens.Routes.Route + alias Screens.Stops.Stop + alias Screens.V2.DisruptionDiagram, as: DD + alias Screens.V2.DisruptionDiagram.Label + alias Screens.V2.LocalizedAlert + + # Vector-related macros + import Aja, only: [vec: 1, vec_size: 1, +++: 2] + + ################## + # HELPER MODULES # + ################## + + defmodule StopSlot do + @moduledoc false + + @enforce_keys [:id, :label, :home_stop?, :disrupted?, :terminal?] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + id: Stop.id(), + label: DD.label_map(), + home_stop?: boolean(), + disrupted?: boolean(), + terminal?: boolean() + } + end + + defmodule OmittedSlot do + @moduledoc false + + @enforce_keys [:label] + defstruct @enforce_keys + + @type t :: %__MODULE__{label: DD.label()} + end + + defmodule ArrowSlot do + @moduledoc false + + @enforce_keys [:label_id] + defstruct @enforce_keys + + @type t :: %__MODULE__{label_id: DD.end_label_id()} + end + + defmodule Metadata do + @moduledoc false + + @enforce_keys [ + :line, + :effect, + :branch, + :home_stop, + :first_disrupted_stop, + :last_disrupted_stop + ] + defstruct @enforce_keys + + @type t :: %__MODULE__{ + line: DD.line(), + effect: :shuttle | :suspension | :station_closure, + branch: DD.branch(), + first_disrupted_stop: Vector.index(), + last_disrupted_stop: Vector.index(), + home_stop: Vector.index() + } + end + + ############### + # MAIN MODULE # + ############### + + @enforce_keys [:sequence, :metadata] + defstruct @enforce_keys ++ [left_end: Vector.new(), right_end: Vector.new()] + + @type t :: %__MODULE__{ + # The main sequence of slots in the diagram. + sequence: sequence(), + # Information about the diagram as a whole, including indexes of important stops. + metadata: metadata(), + # The ends are "bags" of stops that are outside the main area of the diagram. + # Stops can be transferred between the `sequence` and the ends during the process of building the diagram. + # Each end serializes to at most 1 slot in the final diagram. + # During serialization, we inspect the contents of each end to determine what the first + # and last slot should be. + left_end: end_sequence(), + right_end: end_sequence() + } + + # Starts out only containing StopSlots, but may contain other slot types + # as we work our way toward building the final diagram output. + @opaque sequence :: Vector.t(StopSlot.t() | OmittedSlot.t() | ArrowSlot.t()) + + @opaque end_sequence :: Vector.t(StopSlot.t() | ArrowSlot.t()) + + @opaque metadata :: Metadata.t() + + @doc "Creates a new Builder from a localized alert." + @spec new(LocalizedAlert.t()) :: {:ok, t()} | {:error, reason :: String.t()} + def new(localized_alert) do + informed_stop_ids = + for %{stop: "place-" <> _ = stop_id} <- localized_alert.alert.informed_entities, + into: MapSet.new(), + do: stop_id + + with {:ok, route_id, stop_sequence, branch} <- + get_builder_data(localized_alert, informed_stop_ids) do + line = Route.get_color_for_route(route_id) + + stop_id_to_name = Stop.stop_id_to_name(route_id) + + slot_sequence = + stop_sequence + |> Vector.new(fn stop_id -> + {full, abbrev} = Map.fetch!(stop_id_to_name, stop_id) + + %StopSlot{ + id: stop_id, + label: %{full: full, abbrev: abbrev}, + home_stop?: stop_id == localized_alert.location_context.home_stop, + disrupted?: stop_id in informed_stop_ids, + terminal?: false + } + end) + |> adjust_ends(line, branch) + + init_metadata = %Metadata{ + line: line, + branch: branch, + effect: localized_alert.alert.effect, + # These will get the correct values during the first `recalculate_metadata` run below. + home_stop: -1, + first_disrupted_stop: -1, + last_disrupted_stop: -1 + } + + builder = + %__MODULE__{sequence: slot_sequence, metadata: init_metadata} + |> recalculate_metadata() + |> split_end_stops() + + {:ok, builder} + end + end + + @doc """ + Reverses the builder's internal stop sequence, so that the last stop comes first and vice versa. + + This is helpful for cases where the disruption diagram lists stops in the opposite order of + the direction_id=0 route order, e.g. in Blue Line diagrams where we show Bowdoin first but + direction_id=0 has Wonderland listed first. + """ + @spec reverse(t()) :: t() + def reverse(%__MODULE__{} = builder) do + %{ + builder + | sequence: Vector.reverse(builder.sequence), + # The ends swap places, and also have their elements flipped. + left_end: Vector.reverse(builder.right_end), + right_end: Vector.reverse(builder.left_end) + } + |> recalculate_metadata() + end + + @doc """ + Tries to omit stops from the given region, replacing them with a labeled "blank" slot, or two in rare cases. + `target_slots` gives the desired number of remaining slots in the region after omission. + + Stops are omitted from the center of the region, unless that would result + in the omission of the home stop or a bypassed stop. + In that case, we try to find another segment, or segments, of stops to omit, staying as close to the center as possible. + + Returns an error result if it's not possible to omit the required number of stops without + also omitting the home stop or a bypassed stop. + """ + @spec try_omit_stops(t(), :closure | :gap, pos_integer()) :: + {:ok, t()} | {:error, reason :: String.t()} + def try_omit_stops(builder, region, target_slots) + + def try_omit_stops(%__MODULE__{} = builder, :closure, target_closure_slots) do + try_omit(builder, closure_indices(builder), target_closure_slots) + end + + def try_omit_stops(%__MODULE__{} = builder, :gap, target_gap_stops) do + try_omit(builder, gap_indices(builder), target_gap_stops) + end + + @doc """ + Moves `num_to_add` stops back from the left/right end groups to the main sequence, + effectively "padding" the diagram with stops that otherwise would have been + omitted inside one of the destination-arrow slots. + Stops are added from the end closest to the home stop, unless it's empty. + In that case, they are added from the opposite end. + """ + @spec add_slots(t(), pos_integer()) :: t() + def add_slots(%__MODULE__{} = builder, num_to_add) do + closure_region_indices = closure_indices(builder) + + home_stop_is_right_of_center = builder.metadata.home_stop > center(closure_region_indices) + + pull_from = if home_stop_is_right_of_center, do: :right_end, else: :left_end + + builder + |> do_add_slots(num_to_add, pull_from) + |> recalculate_metadata() + end + + @doc "Serializes the builder to a DisruptionDiagram.serialized_response()." + @spec serialize(t()) :: DD.serialized_response() + def serialize(%__MODULE__{} = builder) do + builder = add_back_end_slots(builder) + + base_data = %{ + effect: builder.metadata.effect, + line: builder.metadata.line, + current_station_slot_index: builder.metadata.home_stop, + slots: serialize_sequence(builder) + } + + if base_data.effect == :station_closure do + Map.put( + base_data, + :closed_station_slot_indices, + disrupted_stop_indices(builder) + ) + else + range = + builder + |> disrupted_stop_indices() + |> Enum.min_max() + + Map.put(base_data, :effect_region_slot_index_range, range) + end + end + + @doc """ + Returns the number of slots that would be in the diagram produced by the current builder. + """ + @spec slot_count(t()) :: non_neg_integer() + def slot_count(%__MODULE__{} = builder) do + left_end_slot_count = min(vec_size(builder.left_end), 1) + right_end_slot_count = min(vec_size(builder.right_end), 1) + + vec_size(builder.sequence) + left_end_slot_count + right_end_slot_count + end + + @doc """ + Returns the number of stops comprising the closure region of the diagram. + + **This can be different from the number of disrupted stops!** + + For station closures, we count from the stop on the left of the first bypassed stop to the stop on the right of the last bypassed stop: + O === O === X === O === X === X === O === O + |-----------------------------| + count = 6 + + For shuttles and suspensions, it's just the stops that are directly informed by the alert: + O === O === X - - X - - X - - X === O === O + |-----------------| + count = 4 + """ + @spec closure_count(t()) :: non_neg_integer() + def closure_count(%__MODULE__{} = builder) do + builder + |> closure_indices() + |> Enum.count() + end + + @doc """ + Returns the number of stops comprising the gap region of the diagram. + + This is always the stops between the closure region and the home stop. + """ + @spec gap_count(t()) :: non_neg_integer() + def gap_count(%__MODULE__{} = builder) do + Enum.count(gap_indices(builder)) + end + + @doc """ + Returns the number of stops comprising the "current location" region + of the diagram. + + This is normally 2: the actual home stop, and its adjacent stop + on the far side of the closure. Its adjacent stop on the near side is + part of the gap. + + The number is lower when the closure region overlaps with this region, + or when the home stop is at/near a terminal. + """ + @spec current_location_count(t()) :: non_neg_integer() + def current_location_count(%__MODULE__{} = builder) do + builder + |> current_location_indices() + |> Enum.count() + end + + @doc """ + Returns the number of stops comprising the ends of the diagram. + + This is normally 2, unless another region contains either terminal stop of the line. + """ + @spec end_count(t()) :: non_neg_integer() + def end_count(%__MODULE__{} = builder) do + min(1, vec_size(builder.left_end)) + min(1, vec_size(builder.right_end)) + end + + @spec line(t()) :: DD.line() + def line(%__MODULE__{} = builder), do: builder.metadata.line + + @spec branch(t()) :: DD.branch() + def branch(%__MODULE__{} = builder), do: builder.metadata.branch + + @doc """ + Returns true if this diagram is + - for a Green Line alert, + - includes at least one GLX stop (past Lechmere), and + - does not extend west of Copley. + """ + @spec glx_only?(t()) :: boolean() + def glx_only?(%__MODULE__{} = builder) do + is_glx_branch = builder.metadata.branch in [:d, :e] + + diagram_contains_glx = + Aja.Enum.any?(builder.sequence, fn + %StopSlot{} = stop_data -> Stop.on_glx?(stop_data.id) + _ -> false + end) + + copley_index = + Aja.Enum.find_index(builder.sequence, fn + %StopSlot{id: "place-coecl"} -> true + _ -> false + end) + + no_stops_west_of_copley = + case copley_index do + nil -> true + # If Copley is in the sequence, it can only be the last stop + i -> i == vec_size(builder.sequence) - 1 + end + + is_glx_branch and diagram_contains_glx and no_stops_west_of_copley + end + + # Gets all the stuff we need to assemble the struct. + @spec get_builder_data(LocalizedAlert.t(), MapSet.t(Stop.id())) :: + {:ok, informed_route :: Route.id(), stop_sequence :: list(Stop.id()), DD.branch()} + | {:error, String.t()} + defp get_builder_data(localized_alert, informed_stop_ids) do + stops_in_diagram = MapSet.put(informed_stop_ids, localized_alert.location_context.home_stop) + + matching_tagged_sequences = + Enum.flat_map(localized_alert.location_context.tagged_stop_sequences, fn {route, sequences} -> + sequences + |> Enum.filter(&MapSet.subset?(stops_in_diagram, MapSet.new(&1))) + |> Enum.map(&{route, &1}) + end) + + informed_route_id = + Enum.find_value(localized_alert.alert.informed_entities, fn + %{route: "Green" <> _ = route_id} -> route_id + %{route: route_id} when route_id in ["Blue", "Orange", "Red"] -> route_id + _ -> false + end) + + do_get_data(matching_tagged_sequences, informed_route_id) + end + + defp do_get_data([], _) do + {:error, "no stop sequence contains both the home stop and all informed stops"} + end + + # A single Green Line branch + defp do_get_data([{"Green-" <> branch_letter = route_id, sequence}], _) do + branch = + branch_letter + |> String.downcase() + |> String.to_existing_atom() + + {:ok, route_id, sequence, branch} + end + + # A single Red Line branch + defp do_get_data([{"Red", sequence}], _) do + branch = if "place-asmnl" in sequence, do: :ashmont, else: :braintree + + {:ok, "Red", sequence, branch} + end + + # A single non-branching route + defp do_get_data([{route_id, sequence}], _) do + {:ok, route_id, sequence, :trunk} + end + + # 2+ routes + defp do_get_data(matches, informed_route_id) do + cond do + Enum.all?(matches, &match?({"Green-" <> _, _}, &1)) -> + # Green Line trunk + {:ok, "Green", gl_trunk_stop_sequence(), :trunk} + + Enum.all?(matches, &match?({"Red", _}, &1)) -> + # Red Line trunk + {:ok, "Red", rl_trunk_stop_sequence(), :trunk} + + # The remaining cases are for when 2+ lines contain the stop(s). We defer to informed route. + # Only core stops are served by more than one line, so we'll use the trunk sequences for GL/RL. + String.starts_with?(informed_route_id, "Green") -> + # Green Line trunk, probably at North Station, Haymarket, Government Center, or Park Street + {:ok, "Green", gl_trunk_stop_sequence(), :trunk} + + informed_route_id == "Red" -> + # Red Line trunk, probably at Park Street or Downtown Crossing + {:ok, "Red", rl_trunk_stop_sequence(), :trunk} + + true -> + # Orange Line, probably at North Station, Haymarket, State, or Downtown Crossing + # or Blue Line, probably at Government Center or State + {:ok, informed_route_id, Stop.get_route_stop_sequence(informed_route_id), :trunk} + end + end + + defp gl_trunk_stop_sequence do + Enum.map(Stop.gl_trunk_stops(), fn {stop_id, _labels} -> stop_id end) + end + + defp rl_trunk_stop_sequence do + Enum.map(Stop.rl_trunk_stops(), fn {stop_id, _labels} -> stop_id end) + end + + # Adjusts the left and right ends of the sequence before we split them off into `left_end` and `right_end`. + # - Mark terminal stops as such + # - For branching ends of trunk sequences (JFK, Lechmere, Kenmore), add `ArrowSlot`s with labels for those branches. + defp adjust_ends(sequence, line, branch) + + defp adjust_ends(sequence, :green, :trunk) do + # The Green Line trunk (Lechmere to Kenmore) has branches at both ends. + sequence + |> Vector.prepend(%ArrowSlot{label_id: "place-mdftf+place-unsqu"}) + |> Vector.append(%ArrowSlot{label_id: "western_branches"}) + end + + defp adjust_ends(sequence, :red, :trunk) do + # The Red Line trunk (Alewife to JFK) has a terminal at Alewife and branches past JFK. + sequence + |> Vector.update_at!(0, &%{&1 | terminal?: true}) + |> Vector.append(%ArrowSlot{label_id: "place-asmnl+place-brntn"}) + end + + defp adjust_ends(sequence, _line, _branch) do + # All other stop sequences have terminals at both ends. + sequence + |> Vector.update_at!(0, &%{&1 | terminal?: true}) + |> Vector.update_at!(-1, &%{&1 | terminal?: true}) + end + + # Removes stops outside the closure/current location regions from the main sequence, and puts them into the ends. + # O = O = O = O = X = X = X = X = O = O = <> = O = O = O = => + # ^ ^ ^ ^ ^ ^ ^ + # Moved to left_end Moved to right_end + defp split_end_stops(builder) when builder.metadata.line == :blue do + # Since we always show all stops for the Blue Line, we don't need to do + # anything special with the ends. They don't need to be split out. + + builder + end + + defp split_end_stops(builder) do + # In all other cases, we split out the left and right ends. + + in_diagram = + [ + closure_indices(builder), + gap_indices(builder), + # We can save a little work by using the "ideal" indices here, since + # any overlap will disappear when we drop these into a MapSet. + current_location_ideal_indices(builder) + ] + |> Enum.concat() + |> MapSet.new() + + {leftmost_stop_index, rightmost_stop_index} = Enum.min_max(in_diagram) + + # Example: If the first one we're keeping is at index 5, + # then it's the 6th element so we need to slice off the first 5. + left_slice_amount = leftmost_stop_index + + last_index = Vector.size(builder.sequence) - 1 + right_slice_amount = last_index - rightmost_stop_index + + builder + |> split_end(:right_end, right_slice_amount) + |> split_end(:left_end, left_slice_amount) + |> recalculate_metadata() + end + + defp split_end(builder, end_field, 0), do: %{builder | end_field => Vector.new()} + + defp split_end(builder, :left_end, amount) do + {left_end, sequence} = Vector.split(builder.sequence, amount) + + # (We expect recalculate_metadata to be invoked in the calling function, so don't do it here.) + %{builder | sequence: sequence, left_end: left_end} + end + + defp split_end(builder, :right_end, amount) do + {sequence, right_end} = Vector.split(builder.sequence, -amount) + + %{builder | sequence: sequence, right_end: right_end} + end + + # Re-computes index fields (home_stop, first/last_disrupted_stop) + # in builder.metadata after builder.sequence is changed. + # + # This function must be called after any operation that changes builder.sequence. + defp recalculate_metadata(builder) do + # We're going to replace all of the indices, so throw out the old ones. + # That way, if we fail to set one of them (which shouldn't happen), + # the `struct!` call below will fail instead of continuing with missing data. + meta_without_indices = + builder.metadata + |> Map.from_struct() + |> Map.drop([:home_stop, :first_disrupted_stop, :last_disrupted_stop]) + + indexed_sequence = Vector.with_index(builder.sequence) + + home_stop = + Aja.Enum.find_value(indexed_sequence, fn + {%StopSlot{home_stop?: true}, i} -> i + _ -> false + end) + + first_disrupted_stop = + Aja.Enum.find_value(indexed_sequence, fn + {%StopSlot{disrupted?: true}, i} -> i + _ -> false + end) + + last_disrupted_stop = + indexed_sequence + |> Vector.reverse() + |> Aja.Enum.find_value(fn + {%StopSlot{disrupted?: true}, i} -> i + _ -> false + end) + + new_metadata = + meta_without_indices + |> Map.merge(%{ + home_stop: home_stop, + first_disrupted_stop: first_disrupted_stop, + last_disrupted_stop: last_disrupted_stop + }) + |> then(&struct!(Metadata, &1)) + + %{builder | metadata: new_metadata} + end + + defp try_omit(builder, current_region_indices, target_slots) do + region_length = Enum.count(current_region_indices) + + if target_slots >= region_length do + raise "Nothing to omit, function should not have been called" + end + + # We need to omit 1 more stop than the difference, to account for the omission itself, which still takes up one slot: + # + # region: X - - X - - X - - X - - X :: length 5 + # target_slots: 3 + # + # 5 - 3 + 1 = 3 stops to omit (not 2!) + # + # after omission: X - - ... - - X :: length 3 + num_to_omit = region_length - target_slots + 1 + + num_to_keep = region_length - num_to_omit + + home_stop_is_right_of_center = builder.metadata.home_stop > center(current_region_indices) + + # If the number of slots to keep is odd, more slots are devoted to the side of the region nearest the home stop. + offset = + if rem(num_to_keep, 2) == 1 and not home_stop_is_right_of_center do + # num_to_keep is odd and the home stop is NOT to the right of the closure center. + div(num_to_keep, 2) + 1 + else + # num_to_keep is even, OR num_to_keep is odd and the home stop is to the right of the closure center. + div(num_to_keep, 2) + end + + omitted_indices = + current_region_indices + |> Enum.drop(offset) + |> Enum.take(num_to_omit) + |> Enum.min_max() + |> then(fn {leftmost_omitted, rightmost_omitted} -> + leftmost_omitted..rightmost_omitted//1 + end) + + important_indices = get_important_indices(builder) + + undesired_omissions = + MapSet.intersection(MapSet.new(omitted_indices), MapSet.new(important_indices)) + + if MapSet.size(undesired_omissions) == 0 do + {:ok, do_omit(builder, omitted_indices)} + else + try_alternate_omit(builder, omitted_indices, important_indices) + end + end + + # Returns a sorted vector containing indices of stops that can't be omitted from the closure region. + defp get_important_indices(builder) do + closure_first..closure_last//1 = closure = closure_indices(builder) + + [ + closure_first, + closure_last, + builder.metadata.home_stop in closure and builder.metadata.home_stop, + builder.metadata.effect == :station_closure and disrupted_stop_indices(builder) + ] + |> Enum.filter(& &1) + |> List.flatten() + |> Enum.sort() + |> Vector.new() + end + + defp do_omit(builder, omitted_indices) do + label = + omitted_indices + |> MapSet.new(&builder.sequence[&1].id) + |> Label.get_omission_label(builder.metadata.line, builder.metadata.branch) + + {first_omitted, last_omitted} = Enum.min_max(omitted_indices) + + builder + |> update_in([Access.key(:sequence)], fn seq -> + left_side = Vector.slice(seq, 0..(first_omitted - 1)//1) + right_side = Vector.slice(seq, (last_omitted + 1)..-1//1) + + left_side +++ Vector.new([%OmittedSlot{label: label}]) +++ right_side + end) + |> recalculate_metadata() + end + + # Handles rare cases where we can't omit stops from the center of the closure. + # - First, it tries to find a segment of "omission-safe" stops to one side of the center, searching from the center outward. + # - If there are no segments wide enough, it then tries to do the omission in two places. + # - If it's still not possible to reduce the slots to the target amount without omitting + # an important stop, it gives up and returns an error tuple. + defp try_alternate_omit(builder, original_omission, important_indices) do + with :error <- try_side_omit(builder, original_omission, important_indices), + :error <- try_split_omit(builder, original_omission, important_indices) do + n = Range.size(original_omission) + msg = "can't omit #{n} from closure region without omitting at least one important stop" + + {:error, msg} + end + end + + defp try_side_omit(builder, original_omission, important_indices) do + left_try = find_safe_segment(original_omission, important_indices, :left) + right_try = find_safe_segment(original_omission, important_indices, :right) + + case {left_try, right_try} do + {:error, :error} -> + :error + + {{:ok, safe_omission_left, _offset}, :error} -> + {:ok, do_omit(builder, safe_omission_left)} + + {:error, {:ok, safe_omission_right, _offset}} -> + {:ok, do_omit(builder, safe_omission_right)} + + both_safe -> + both_safe + |> Tuple.to_list() + |> Enum.min_by(fn {:ok, _omission, offset} -> offset end) + |> then(fn {:ok, safe_omission, _offset} -> {:ok, do_omit(builder, safe_omission)} end) + end + end + + defp try_split_omit(builder, original_omission, important_indices) do + # A second omission means a second label-- + # we need to omit one additional stop to still reach the target region length. + omit_count = 1 + Range.size(original_omission) + + closure_first = Vector.first(important_indices) + closure_last = Vector.last(important_indices) + + center_index = center(closure_first..closure_last//1) + + # Find all safe segments, sort the longest ones first, and split those to the left + # of the closure center from those to the right. + {left_segments, right_segments} = + important_indices + |> Enum.chunk_every(2, 1, :discard) + |> Enum.map(fn [left_important, right_important] -> + (left_important + 1)..(right_important - 1)//1 + end) + |> Enum.reject(&(Range.size(&1) == 0)) + |> Enum.sort_by(&Range.size/1, :desc) + |> Enum.split_with(&(center(&1) <= center_index)) + + left1 = Enum.at(left_segments, 0, empty_range()) + left2 = Enum.at(left_segments, 1, empty_range()) + + right1 = Enum.at(right_segments, 0, empty_range()) + right2 = Enum.at(right_segments, 1, empty_range()) + + # First, try to omit from either side of the center. + # If that's not possible, try omitting in two different places to one side of the center. + # After that, give up! + segment_pair = + cond do + Range.size(left1) + Range.size(right1) >= omit_count -> + {Enum.reverse(left1), Enum.to_list(right1)} + + Range.size(left1) + Range.size(left2) >= omit_count -> + {Enum.reverse(left1), Enum.reverse(left2)} + + Range.size(right1) + Range.size(right2) >= omit_count -> + {Enum.to_list(right1), Enum.to_list(right2)} + + true -> + :error + end + + with {_segment1, _segment2} <- segment_pair do + {left_omission, right_omission} = select_split_omission_indices(segment_pair, omit_count) + + # We *must* do the right omission before the left, to avoid having the indices change underneath us. + builder = + builder + |> do_omit(right_omission) + |> do_omit(left_omission) + + {:ok, builder} + end + end + + # Evenly pulls indices from the left and right segments until acc contains enough indices. + defp select_split_omission_indices(segment_pair, omit_count, l_acc \\ [], r_acc \\ []) + + defp select_split_omission_indices({l, r}, omit_count, l_acc, r_acc) do + select_split_omission_indices(l, r, omit_count, l_acc, r_acc) + end + + defp select_split_omission_indices(_l, _r, 0, l_acc, r_acc), do: {l_acc, r_acc} + + defp select_split_omission_indices([], [h | t], n, l_acc, r_acc) do + select_split_omission_indices([], t, n - 1, l_acc, [h | r_acc]) + end + + defp select_split_omission_indices([h | t], [], n, l_acc, r_acc) do + select_split_omission_indices(t, [], n - 1, [h | l_acc], r_acc) + end + + defp select_split_omission_indices([h | t], r, n, l_acc, r_acc) + when length(l_acc) <= length(r_acc) do + select_split_omission_indices(t, r, n - 1, [h | l_acc], r_acc) + end + + defp select_split_omission_indices(l, [h | t], n, l_acc, r_acc) do + select_split_omission_indices(l, t, n - 1, l_acc, [h | r_acc]) + end + + # Searches for a contiguous segment of stops, none of which are important, which + # we can omit from the diagram. + # + # The search starts from the original desired omission near the center of the region + # and moves outward, either left or right depending on the `side` argument, + # returning either {:ok, safe_segment} or :error if none is found. + defp find_safe_segment(original_omission, important_indices, side, offset \\ 1) + + defp find_safe_segment(original_omission, important_indices, :left, offset) do + _l..r//1 = original_omission + + tl..tr//1 = tentative_omission = shift_range(original_omission, -offset) + + if tl <= Vector.first(important_indices) or tr >= Vector.last(important_indices) do + :error + else + first_overlap = + important_indices + |> Vector.reverse() + |> Aja.Enum.find(&(&1 in tentative_omission)) + + case first_overlap do + nil -> + {:ok, tentative_omission, offset} + + i -> + # The tentative window contains an important index. Move the window past the first important index and try again. + find_safe_segment(original_omission, important_indices, :left, 1 + r - i) + end + end + end + + defp find_safe_segment(original_omission, important_indices, :right, offset) do + l.._r//1 = original_omission + + tl..tr//1 = tentative_omission = shift_range(original_omission, offset) + + if tl <= Vector.first(important_indices) or tr >= Vector.last(important_indices) do + :error + else + first_overlap = Aja.Enum.find(important_indices, &(&1 in tentative_omission)) + + case first_overlap do + nil -> + {:ok, tentative_omission, offset} + + i -> + # The tentative window contains an important index. Move the window past the first important index and try again. + find_safe_segment(original_omission, important_indices, :right, 1 + i - l) + end + end + end + + defp do_add_slots(builder, 0, _), do: builder + + defp do_add_slots(builder, _greater_than_0, _) + when vec_size(builder.left_end) == 0 and vec_size(builder.right_end) == 0 do + # There are no more end stops available on either side. + # This code is probably running in a test case if the stop sequence is that small. + # Just return the builder. + builder + end + + defp do_add_slots(builder, num_to_add, :left_end) + when vec_size(builder.left_end) == 0 do + do_add_slots(builder, num_to_add, :right_end) + end + + defp do_add_slots(builder, num_to_add, :right_end) + when vec_size(builder.right_end) == 0 do + do_add_slots(builder, num_to_add, :left_end) + end + + defp do_add_slots(builder, num_to_add, :right_end) do + {stop_data, new_right_end} = Vector.pop_at(builder.right_end, 0) + + # If we just added the last slot from the right end, all we did was move + # a terminal/arrow back into the main sequence. + # Effectively, nothing was added to the diagram. + new_num_to_add = if vec_size(new_right_end) > 0, do: num_to_add - 1, else: num_to_add + + builder + |> put_in([Access.key(:right_end)], new_right_end) + |> update_in([Access.key(:sequence)], &Vector.append(&1, stop_data)) + |> do_add_slots(new_num_to_add, :right_end) + end + + defp do_add_slots(builder, num_to_add, :left_end) do + {stop_data, new_left_end} = Vector.pop_last!(builder.left_end) + + # If we just added the last slot from the left end, all we did was move + # a terminal/arrow back into the main sequence. + # Effectively, nothing was added to the diagram. + new_num_to_add = if vec_size(new_left_end) > 0, do: num_to_add - 1, else: num_to_add + + builder + |> put_in([Access.key(:left_end)], new_left_end) + |> update_in([Access.key(:sequence)], &Vector.prepend(&1, stop_data)) + |> do_add_slots(new_num_to_add, :left_end) + end + + defp serialize_sequence(%__MODULE__{} = builder) do + Aja.Enum.map(builder.sequence, fn + %ArrowSlot{} = arrow -> %{type: :arrow, label_id: arrow.label_id} + %StopSlot{} = stop when stop.terminal? -> %{type: :terminal, label_id: stop.id} + %StopSlot{} = stop -> %{label: stop.label, show_symbol: true} + %OmittedSlot{} = omitted -> %{label: omitted.label, show_symbol: false} + end) + end + + # Re-adds each of left_end and right_end to the main sequence as either: + # - a terminal stop slot if the end contains 1 stop, + # - a destination-arrow slot if the end contains multiple stops, or + # - nothing if the end contains no stops. + defp add_back_end_slots(builder) do + left_end = get_end_slot(builder.metadata, builder.left_end) + right_end = get_end_slot(builder.metadata, builder.right_end) + + %{builder | sequence: left_end +++ builder.sequence +++ right_end} + |> recalculate_metadata() + end + + defp get_end_slot(_meta, vec([])), do: Vector.new() + + defp get_end_slot(_meta, vec([%{terminal?: true} = stop_data])), do: Vector.new([stop_data]) + + defp get_end_slot(_meta, vec([%ArrowSlot{} = predefined_destination])), + do: Vector.new([predefined_destination]) + + defp get_end_slot(meta, stops) do + stop_ids = + stops + |> Vector.filter(&is_struct(&1, StopSlot)) + |> MapSet.new(& &1.id) + + label_id = Label.get_end_label_id(stop_ids, meta.line, meta.branch) + + Vector.new([%ArrowSlot{label_id: label_id}]) + end + + # Returns a sorted list of indices of the stops that are in the alert's informed entities. + # For station closures, this is the stops that are bypassed. + # For shuttles and suspensions, this is the stops that don't have any train service + # *as well as* the stops at the boundary of the disruption that don't have train service in one direction. + defp disrupted_stop_indices(%__MODULE__{} = builder) do + builder.sequence + |> Vector.with_index() + |> Vector.filter(fn + {%StopSlot{} = stop_data, _i} -> stop_data.disrupted? + {_other_slot_type, _i} -> false + end) + |> Aja.Enum.map(fn {_stop_data, i} -> i end) + end + + # The closure has highest priority, so no other overlapping region can take stops from it. + defp closure_indices(%{metadata: %{effect: :station_closure}} = builder) do + # first = One stop before the first bypassed stop, if it exists. Otherwise, the first bypassed stop. + first = clamp(builder.metadata.first_disrupted_stop - 1, vec_size(builder.sequence)) + + # last = One stop past the last bypassed stop, if it exists. Otherwise, the last bypassed stop. + last = clamp(builder.metadata.last_disrupted_stop + 1, vec_size(builder.sequence)) + + first..last//1 + end + + defp closure_indices(%{metadata: %{effect: continuous} = metadata}) + when continuous in [:shuttle, :suspension] do + metadata.first_disrupted_stop..metadata.last_disrupted_stop//1 + end + + # The gap region has second highest priority and by its definition doesn't overlap with the closure region. + defp gap_indices(builder) do + home_stop = builder.metadata.home_stop + + closure_left..closure_right = closure_indices(builder) + + cond do + home_stop < closure_left -> (home_stop + 1)..(closure_left - 1)//1 + home_stop > closure_right -> (closure_right + 1)..(home_stop - 1)//1 + true -> empty_range() + end + end + + # The current location region can be subsumed by the closure and the gap regions. + defp current_location_indices(builder) do + current_location_region = MapSet.new(current_location_ideal_indices(builder)) + + gap_region = MapSet.new(gap_indices(builder)) + closure_region = MapSet.new(closure_indices(builder)) + + current_location_region + |> MapSet.difference(MapSet.union(gap_region, closure_region)) + |> Enum.min_max(fn -> :its_empty end) + |> case do + {left, right} -> left..right//1 + :its_empty -> empty_range() + end + end + + # Indices of the current location region if none were taken by other higher-precedence regions. + defp current_location_ideal_indices(builder) do + home_stop = builder.metadata.home_stop + + size = vec_size(builder.sequence) + + clamp(home_stop - 1, size)..clamp(home_stop + 1, size)//1 + end + + # (Just left of center if length is even.) + defp center(l..r//1) when r >= l do + l + div(r - l, 2) + end + + # Adjusts an index to be within the bounds of the stop sequence. + defp clamp(index, _sequence_size) when index < 0, do: 0 + defp clamp(index, sequence_size) when index >= sequence_size, do: sequence_size - 1 + defp clamp(index, _sequence_size), do: index + + # Returns a range of size 0. + # When used to slice an enumerable, it returns the whole enumerable (from index 0 to index -1, the last element). + # When we upgrade to Elixir 1.14, this can be replaced with just `..`. + # https://hexdocs.pm/elixir/Kernel.html#../0 + defp empty_range, do: 0..-1//1 + + # Shifts a range by the given number of steps. + # When we upgrade to Elixir 1.14, this can be replaced with `Range.shift/2`. + def shift_range(first..last//step, steps_to_shift) + when is_integer(first) and is_integer(last) and is_integer(step) and + is_integer(steps_to_shift) do + Range.new(first + steps_to_shift * step, last + steps_to_shift * step, step) + end +end diff --git a/lib/screens/v2/disruption_diagram/label.ex b/lib/screens/v2/disruption_diagram/label.ex new file mode 100644 index 000000000..7551ecc74 --- /dev/null +++ b/lib/screens/v2/disruption_diagram/label.ex @@ -0,0 +1,112 @@ +defmodule Screens.V2.DisruptionDiagram.Label do + @moduledoc """ + Functions for labeling disruption diagram slots. + """ + + alias Screens.Stops.Stop + alias Screens.V2.DisruptionDiagram, as: DD + + @doc "Returns the label for an omitted slot." + @spec get_omission_label(MapSet.t(Stop.id()), DD.line(), DD.branch()) :: DD.label() + def get_omission_label(omitted_stop_ids, :green, branch_thru_kenmore) + when branch_thru_kenmore in [:b, :c, :d] do + # For GL branches that pass through Kenmore, we look for Kenmore and Copley. + [ + "place-kencl" in omitted_stop_ids and "Kenmore", + "place-coecl" in omitted_stop_ids and "Copley" + ] + |> Enum.filter(& &1) + |> Enum.join(" & ") + |> case do + "" -> "…" + stop_names -> %{full: "…via #{stop_names}", abbrev: "…via #{stop_names}"} + end + end + + def get_omission_label(omitted_stop_ids, :green, _trunk_or_e_branch) do + # For E branch and trunk, we look for Government Center only. + if "place-gover" in omitted_stop_ids, + do: %{full: "…via Government Center", abbrev: "…via Gov't Ctr"}, + else: "…" + end + + # Orange and Red Lines both only look for Downtown Crossing. + def get_omission_label(omitted_stop_ids, line, _) when line in [:orange, :red] do + if "place-dwnxg" in omitted_stop_ids, + do: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + else: "…" + end + + @doc "Returns the label ID for an end that contains more than one item." + @spec get_end_label_id(MapSet.t(Stop.id()), DD.line(), DD.branch()) :: DD.end_label_id() + def get_end_label_id(end_stop_ids, :orange, _) do + cond do + "place-forhl" in end_stop_ids -> "place-forhl" + "place-ogmnl" in end_stop_ids -> "place-ogmnl" + end + end + + def get_end_label_id(end_stop_ids, :red, :trunk) do + cond do + "place-alfcl" in end_stop_ids -> "place-alfcl" + "place-jfk" in end_stop_ids -> "place-asmnl+place-brntn" + end + end + + def get_end_label_id(end_stop_ids, :red, :ashmont) do + cond do + "place-alfcl" in end_stop_ids -> "place-alfcl" + "place-asmnl" in end_stop_ids -> "place-asmnl" + end + end + + def get_end_label_id(end_stop_ids, :red, :braintree) do + cond do + "place-alfcl" in end_stop_ids -> "place-alfcl" + "place-brntn" in end_stop_ids -> "place-brntn" + end + end + + def get_end_label_id(end_stop_ids, :green, :trunk) do + cond do + # left end + "place-lech" in end_stop_ids -> "place-mdftf+place-unsqu" + # right end + # vvv + "place-north" in end_stop_ids -> "place-north+place-pktrm" + "place-gover" in end_stop_ids -> "place-gover" + # ^^^ These two labels are not possible to produce. + # Diagrams for trunk alerts not extending past these stops are too small and will be padded to include them. + "place-coecl" in end_stop_ids -> "place-coecl+west" + "place-kencl" in end_stop_ids -> "place-kencl+west" + end + end + + def get_end_label_id(end_stop_ids, :green, :b) do + cond do + "place-gover" in end_stop_ids -> "place-gover" + "place-lake" in end_stop_ids -> "place-lake" + end + end + + def get_end_label_id(end_stop_ids, :green, :c) do + cond do + "place-gover" in end_stop_ids -> "place-gover" + "place-clmnl" in end_stop_ids -> "place-clmnl" + end + end + + def get_end_label_id(end_stop_ids, :green, :d) do + cond do + "place-unsqu" in end_stop_ids -> "place-unsqu" + "place-river" in end_stop_ids -> "place-river" + end + end + + def get_end_label_id(end_stop_ids, :green, :e) do + cond do + "place-mdftf" in end_stop_ids -> "place-mdftf" + "place-hsmnl" in end_stop_ids -> "place-hsmnl" + end + end +end diff --git a/lib/screens/v2/disruption_diagram/model.ex b/lib/screens/v2/disruption_diagram/model.ex index 090cd92cb..11613896d 100644 --- a/lib/screens/v2/disruption_diagram/model.ex +++ b/lib/screens/v2/disruption_diagram/model.ex @@ -1,81 +1,197 @@ defmodule Screens.V2.DisruptionDiagram.Model do @moduledoc """ - Struct and functions to generate and model a disruption diagram. + Functions to generate a disruption diagram from a `LocalizedAlert`. + + Most of the logic is focused on fitting content into at most 14 slots by omitting stops from the Closure, the Gap, and/or the + Ends as necessary. + + The logic reflects the flowchart created by Betsy and viewable [here](https://miro.com/app/board/uXjVP2Hgi18=/). + + # 📕 Terminology + + | Term | Definition | + | :- | :- | + | Slot | A single, labeled "point" on the diagram. Can be a stop, an omitted segment, a terminal stop, or a destination arrow. Slots do not necessarily correspond 1:1 with stops. | + | Region | A group of slots forming one part of the diagram. Regions can overlap or subsume one another, with a consistent order of precedence: Closure > Gap > Current Location > Ends. | + | Closure | The region containing disrupted stops. For station closures, the non-disrupted stops on either end of the disrupted area are also included. | + | Current Location | The region containing this screen's home stop, as well as the stop(s) on either side of it. | + | Gap | The region between the Closure and the screen's home stop. When present, the Gap always takes the Current Location stop closest to the Closure. | + | Ends | The up-to 2 slots at either end of the diagram. These can take the form of either terminal stops, or destination arrows. | """ - # Model fields TBD - defstruct [] - - @type t :: %__MODULE__{} - - @type serialized_response :: continuous_disruption_diagram() | discrete_disruption_diagram() - - @type continuous_disruption_diagram :: %{ - effect: :shuttle | :suspension, - # A 2-element list, giving indices of the effect region's *boundary stops*, inclusive. - # For example in this scenario: - # 0 1 2 3 4 5 6 7 8 - # <= === O ========= O - - X - - X - - X - - O === O - # |---------range---------| - # The range is [3, 7]. - # - # SPECIAL CASE: - # If the range starts at 0 or ends at the last element of the array, - # then the symbol for that terminal stop should use the appropriate - # disruption symbol, not the "normal service" symbol. - # For example if the range is [0, 5], the left end of the - # diagram should use a disruption symbol: - # 0 1 2 3 4 5 6 7 8 - # X - - X - - X - - X - - X - - O ========= O === => - # |------------range------------| - effect_region_slot_index_range: list(non_neg_integer()), - line: line_color(), - current_station_slot_index: non_neg_integer(), - # First and last elements of the list are `end_slot`s, middle elements are `middle_slot`s. - slots: list(slot()) - } - - @type discrete_disruption_diagram :: %{ - effect: :station_closure, - closed_station_slot_indices: list(non_neg_integer()), - line: line_color(), - current_station_slot_index: non_neg_integer(), - # First and last elements of the list are `end_slot`s, middle elements are `middle_slot`s. - slots: list(slot()) - } - - @type slot :: end_slot() | middle_slot() - - @type end_slot :: %{ - type: :arrow | :terminal, - label_id: end_label_id() - } - - @type middle_slot :: %{ - label: label(), - show_symbol: boolean() - } - - @type label :: ellipsis() | %{full: String.t(), abbrev: String.t()} - - # Literally the string "…", but you can't use string literals as types in elixir - @type ellipsis :: String.t() - - # End labels have hardcoded presentation, so we just send an ID for the client to use in - # a lookup. + alias Screens.V2.DisruptionDiagram, as: DD + alias Screens.V2.DisruptionDiagram.Builder, as: B + alias Screens.V2.DisruptionDiagram.Validator + alias Screens.V2.LocalizedAlert + + import LocalizedAlert, only: [is_localized_alert: 1] + + # If the diagram is shorter than 6 slots, we "pad" it until it contains at least 6. + @minimum_slot_count 6 + + # If the closure is longer than 8 stops, it needs to be collapsed. + @max_closure_count 8 + + # When the closure needs to be collapsed, we omit stops + # from it until the diagram contains 12 slots total. + @max_count_with_collapsed_closure 12 + + # When the closure needs to be collapsed, we automatically + # also collapse the gap, making it take 2 slots or fewer. + @max_collapsed_gap_count 2 + + # If everything else fits, we still limit the gap to 3 slots or fewer. + @max_gap_count 3 + + @doc "Produces a JSON-serializable map representing the disruption diagram." + @spec serialize(DD.t()) :: {:ok, DD.serialized_response()} | {:error, reason :: String.t()} + def serialize(localized_alert) when is_localized_alert(localized_alert) do + with :ok <- Validator.validate(localized_alert) do + do_serialize(localized_alert) + end + rescue + error -> + error_string = + Exception.message(error) <> "\n\n" <> Exception.format_stacktrace(__STACKTRACE__) + + {:error, "Exception raised during serialization:\n\n#{error_string}"} + end + + defp do_serialize(localized_alert) do + with {:ok, builder} <- B.new(localized_alert) do + line = B.line(builder) + + serialize_by_line(line, builder) + end + end + + @spec serialize_by_line(DD.line(), B.t()) :: + {:ok, DD.serialized_response()} | {:error, reason :: String.t()} + # The Blue Line is the simplest case. We always show all stops, starting with Bowdoin. + defp serialize_by_line(:blue, builder) do + # The default stop sequence starts with Wonderland, so we need to put the stops in reverse order + # to have Bowdoin appear first on the diagram. + builder + |> B.reverse() + |> B.serialize() + |> then(&{:ok, &1}) + end + + # For the Green Line, we need to reverse the diagram in certain cases, as well as fit regions. + defp serialize_by_line(:green, builder) do + builder = maybe_reverse_gl(builder) + + with {:ok, builder} <- fit_regions(builder) do + {:ok, B.serialize(builder)} + end + end + + # Red Line and Orange Line diagrams never need to be reversed--we just need to fit regions. + defp serialize_by_line(_orange_or_red, builder) do + with {:ok, builder} <- fit_regions(builder) do + {:ok, B.serialize(builder)} + end + end + + # For GL, OL, and RL, it's possible for the stops we need to show in the diagram to span more than the maximum + # number of slots (14). This function replaces segments of stops with single "omitted" slots in + # order to keep the diagram small enough. # - # TBD what these IDs will look like. We might just use parent station IDs. + # In rare cases, the number of stops to show is too *small* and would look awkward, so we instead pad the diagram with + # additional slots, pulling stops in from either side of the disrupted area. # - # The rest of the labels' presentations are computed based on the height of the end labels, - # so we can send actual text for those--it will be dynamically resized to fit. - @type end_label_id :: String.t() + # The fitting process stops after any one of the 3 functions in the `with` expression--`fit_closure_region`, `fit_gap_region`, or + # `pad_slots`--makes a change to the diagram. + defp fit_regions(builder) do + with :unchanged <- fit_closure_region(builder), + :unchanged <- fit_gap_region(builder), + :unchanged <- pad_slots(builder) do + {:ok, builder} + else + {:done, builder} -> {:ok, builder} + {:error, _} = error_result -> error_result + end + end + + # The diagram needs to be flipped whenever it's not a GLX-only alert. + defp maybe_reverse_gl(builder) do + if B.glx_only?(builder) do + builder + else + B.reverse(builder) + end + end - @type line_color :: :blue | :orange | :red | :green + defp fit_closure_region(builder) do + current_closure_count = B.closure_count(builder) + target_closure_count = @max_count_with_collapsed_closure - min_non_closure_slots(builder) - @doc "Produces a JSON-serializable map representing the disruption diagram." - # Update spec when this gets implemented! - @spec serialize(t()) :: nil - def serialize(_model) do - nil + if current_closure_count > @max_closure_count and target_closure_count < current_closure_count do + with {:ok, builder} <- B.try_omit_stops(builder, :closure, target_closure_count) do + {:done, minimize_gap(builder)} + end + else + :unchanged + end + end + + defp minimize_gap(builder) do + current_gap_count = B.gap_count(builder) + target_gap_count = min_gap(builder) + + if target_gap_count < current_gap_count do + # The gap never contains important stops, so `try_omit_stops` will always succeed. + {:ok, builder} = B.try_omit_stops(builder, :gap, target_gap_count) + builder + else + builder + end + end + + defp fit_gap_region(builder) do + current_gap_count = B.gap_count(builder) + closure_count = B.closure_count(builder) + target_gap_slots = baseline_slots(closure_count) - non_gap_slots(builder) + + if current_gap_count >= @max_gap_count and target_gap_slots < current_gap_count do + # The gap never contains important stops, so `try_omit_stops` will always succeed. + {:ok, builder} = B.try_omit_stops(builder, :gap, target_gap_slots) + + {:done, builder} + else + :unchanged + end end + + defp pad_slots(builder) do + current_slot_count = B.slot_count(builder) + + if current_slot_count < @minimum_slot_count do + {:done, B.add_slots(builder, @minimum_slot_count - current_slot_count)} + else + :unchanged + end + end + + defp min_non_closure_slots(builder) do + B.end_count(builder) + B.current_location_count(builder) + min_gap(builder) + end + + # Number of slots used by all regions except the gap, when it doesn't get minimized. + defp non_gap_slots(builder) do + B.end_count(builder) + B.closure_count(builder) + B.current_location_count(builder) + end + + # The minimum possible size of the gap region. + defp min_gap(builder) do + min(B.gap_count(builder), @max_collapsed_gap_count) + end + + for {closure, baseline} <- %{2 => 10, 3 => 10, 4 => 12, 5 => 12, 6 => 14, 7 => 14, 8 => 14} do + defp baseline_slots(unquote(closure)), do: unquote(baseline) + end + + # In rare cases when the home stop is inside the closure region, + # more than 8 slots are available to the closure. + defp baseline_slots(closure) when closure > 8, do: 14 end diff --git a/lib/screens/v2/disruption_diagram/validator.ex b/lib/screens/v2/disruption_diagram/validator.ex new file mode 100644 index 000000000..3ad422e96 --- /dev/null +++ b/lib/screens/v2/disruption_diagram/validator.ex @@ -0,0 +1,92 @@ +defmodule Screens.V2.DisruptionDiagram.Validator do + @moduledoc """ + Validates LocalizedAlerts for compatibility with disruption diagrams: + - The alert is a subway alert with an effect of shuttle, suspension, or station_closure. + - The alert does not inform an entire route. + - If the alert is a shuttle or suspension, it informs at least 2 stops. + - All stops informed by the alert are reachable from the home stop without any transfers. + - in other words, the alert informs stops on only one subway route. + """ + + alias Screens.Alerts.Alert + alias Screens.Alerts.InformedEntity + alias Screens.LocationContext + alias Screens.V2.LocalizedAlert + + @spec validate(LocalizedAlert.t()) :: :ok | {:error, reason :: String.t()} + def validate(localized_alert) do + with :ok <- validate_effect(localized_alert.alert.effect), + :ok <- validate_not_whole_route_disruption(localized_alert.alert), + :ok <- validate_stop_count(localized_alert.alert) do + validate_informed_lines(localized_alert) + end + end + + defp validate_effect(effect) when effect in [:shuttle, :suspension, :station_closure], do: :ok + defp validate_effect(effect), do: {:error, "invalid effect: #{effect}"} + + defp validate_stop_count(%{effect: continuous_effect} = alert) + when continuous_effect in [:shuttle, :suspension] do + informed_stops = + for %{stop: stop, route: route} <- alert.informed_entities, + match?("place-" <> _, stop), + route in ~w[Blue Orange Red Green-B Green-C Green-D Green-E], + uniq: true, + do: stop + + if length(informed_stops) >= 2 do + :ok + else + {:error, "#{continuous_effect} alert does not inform at least 2 stops"} + end + end + + defp validate_stop_count(_), do: :ok + + defp validate_informed_lines(localized_alert) do + # We can draw a diagram for an alert only when there's a single line to draw. + # + # This is the case when either: + # - The alert informs only one line, or + # - The alert informs multiple lines, but only one of those informed lines serves the home stop. + + localized_alert.alert + |> Alert.informed_subway_routes() + |> consolidate_gl() + |> case do + [_single_line] -> + :ok + + informed_lines -> + lines_serving_stop = + localized_alert.location_context + |> LocationContext.route_ids() + |> consolidate_gl() + + informed_lines_serving_stop = + MapSet.intersection(MapSet.new(informed_lines), MapSet.new(lines_serving_stop)) + + if MapSet.size(informed_lines_serving_stop) == 1 do + :ok + else + {:error, + "alert does not inform exactly one subway line, and home stop location does not help us choose one of the informed lines"} + end + end + end + + defp validate_not_whole_route_disruption(alert) do + if Enum.any?(alert.informed_entities, &InformedEntity.whole_route?/1), + do: {:error, "alert informs an entire route"}, + else: :ok + end + + defp consolidate_gl(route_ids) do + route_ids + |> Enum.map(fn + "Green" <> _ -> "Green" + other -> other + end) + |> Enum.uniq() + end +end diff --git a/lib/screens/v2/localized_alert.ex b/lib/screens/v2/localized_alert.ex index 7bc95205d..075ef9c68 100644 --- a/lib/screens/v2/localized_alert.ex +++ b/lib/screens/v2/localized_alert.ex @@ -9,14 +9,13 @@ defmodule Screens.V2.LocalizedAlert do alias Screens.RouteType alias Screens.Util alias Screens.V2.WidgetInstance.Alert, as: AlertWidget - alias Screens.V2.WidgetInstance.{DupAlert, ElevatorStatus, ReconstructedAlert} + alias Screens.V2.WidgetInstance.{DupAlert, ReconstructedAlert} alias ScreensConfig.Screen @type t :: AlertWidget.t() | DupAlert.t() | ReconstructedAlert.t() - | ElevatorStatus.t() | %{ optional(:screen) => Screen.t(), alert: Alert.t(), @@ -29,6 +28,14 @@ defmodule Screens.V2.LocalizedAlert do @green_line_branches ["Green-B", "Green-C", "Green-D", "Green-E"] + @type location :: + :boundary_downstream + | :boundary_upstream + | :downstream + | :elsewhere + | :inside + | :upstream + @typedoc """ A headsign indicating the direction a vehicle is headed in. @@ -40,6 +47,11 @@ defmodule Screens.V2.LocalizedAlert do """ @type headsign :: String.t() | {:adj, String.t()} + defguard is_localized_alert(value) + when is_map(value) and + is_struct(value.alert, Alert) and + is_struct(value.location_context, LocationContext) + @doc """ Determines the headsign of the affected direction of an alert using stop IDs in its informed entities. @@ -135,13 +147,7 @@ defmodule Screens.V2.LocalizedAlert do end end - @spec location(t()) :: - :boundary_downstream - | :boundary_upstream - | :downstream - | :elsewhere - | :inside - | :upstream + @spec location(t()) :: location() def location( %{alert: alert, location_context: location_context}, is_terminal_station \\ false @@ -200,18 +206,13 @@ defmodule Screens.V2.LocalizedAlert do @doc """ Returns all routes affected by an alert. - Used to build route pills for GL e-ink and text for Pre-fare alerts + Green Line route consolidation logic differs by screen type. + Used to build route pills for GL e-ink and text for Pre-fare alerts. """ - @spec informed_subway_routes(t()) :: list(String.t()) - def informed_subway_routes(%{screen: %Screen{app_id: app_id}, alert: alert}) do + @spec consolidated_informed_subway_routes(t()) :: list(String.t()) + def consolidated_informed_subway_routes(%{screen: %Screen{app_id: app_id}, alert: alert}) do alert - |> Alert.informed_entities() - |> Enum.map(fn %{route: route} -> route end) - # If the alert impacts CR or other lines, weed that out - |> Enum.filter(fn e -> - Enum.member?(["Red", "Orange", "Green", "Blue"] ++ @green_line_branches, e) - end) - |> Enum.uniq() + |> Alert.informed_subway_routes() |> consolidate_gl(app_id) end @@ -251,7 +252,7 @@ defmodule Screens.V2.LocalizedAlert do end @spec active_routes_at_stop(t()) :: MapSet.t(route_id()) - defp active_routes_at_stop(%{location_context: %{routes: routes}}) do + def active_routes_at_stop(%{location_context: %{routes: routes}}) do routes |> Enum.filter(& &1.active?) |> MapSet.new(& &1.route_id) diff --git a/lib/screens/v2/location_context.ex b/lib/screens/v2/location_context.ex index 2b603d9ed..bf5202277 100644 --- a/lib/screens/v2/location_context.ex +++ b/lib/screens/v2/location_context.ex @@ -1,13 +1,15 @@ defmodule Screens.LocationContext do @moduledoc false + alias Screens.RoutePatterns.RoutePattern alias Screens.Routes.Route alias Screens.RouteType alias Screens.Stops.Stop @enforce_keys [:home_stop] defstruct home_stop: "", - stop_sequences: [], + home_stop_name: "", + tagged_stop_sequences: %{}, upstream_stops: MapSet.new(), downstream_stops: MapSet.new(), routes: [], @@ -15,10 +17,32 @@ defmodule Screens.LocationContext do @type t :: %__MODULE__{ home_stop: Stop.id(), - stop_sequences: list(list(Stop.id())), + home_stop_name: String.t(), + # Stop sequences through this stop, keyed under their associated routes + tagged_stop_sequences: %{Route.id() => list(list(Stop.id()))}, upstream_stops: MapSet.t(Stop.id()), downstream_stops: MapSet.t(Stop.id()), + # Routes serving this stop routes: list(%{route_id: Route.id(), active?: boolean()}), + # Route types we care about for the alerts of this screen type / place alert_route_types: list(RouteType.t()) } + + @doc """ + Returns IDs of routes that serve this location. + """ + @spec route_ids(t()) :: list(Route.id()) + def route_ids(%__MODULE__{} = t) do + Route.route_ids(t.routes) + end + + @doc """ + Returns the stop sequences of routes that serve this location. + Sequences follow the order of direction_id=0 for their respective routes. + Generally, this means they go from north/east -> south/west. + """ + @spec stop_sequences(t()) :: list(list(Stop.id())) + def stop_sequences(%__MODULE__{} = t) do + RoutePattern.untag_stop_sequences(t.tagged_stop_sequences) + end end diff --git a/lib/screens/v2/screen_data.ex b/lib/screens/v2/screen_data.ex index 9a6c4a1ce..d97ef18e8 100644 --- a/lib/screens/v2/screen_data.ex +++ b/lib/screens/v2/screen_data.ex @@ -61,7 +61,7 @@ defmodule Screens.V2.ScreenData do screen_data = fetch_data(config) full_page_data = screen_data |> resolve_paging(refresh_rate) |> serialize() - paged_slot_data = screen_data |> get_paged_slots() |> serialize_paged_slots() + paged_slot_data = screen_data |> get_paged_slots() |> serialize_paged_slots(config.app_id) response(data: %{full_page: full_page_data, flex_zone: paged_slot_data}) end @@ -404,7 +404,7 @@ defmodule Screens.V2.ScreenData do Template.position_widget_instances(layout, serialized_instance_map, paging_metadata) end - defp serialize_paged_slots({instance_map, layout}) do + defp serialize_paged_slots({instance_map, layout}, app_id) do # instance_map looks like: # %{{page_index, slot_id} => instance} @@ -413,7 +413,7 @@ defmodule Screens.V2.ScreenData do instance_map |> Enum.group_by( - fn {paged_slot_id, _} -> Template.get_page(paged_slot_id) end, + &paged_slot_key(&1, app_id), fn {paged_slot_id, instance} -> {Template.unpage(paged_slot_id), instance} end ) # %{page_index => [{slot_id, instance}]} @@ -492,4 +492,7 @@ defmodule Screens.V2.ScreenData do :ok = ScreensByAlert.put_data(screen_id, alert_ids) end + + defp paged_slot_key({paged_slot_id, _}, :pre_fare_v2), do: Template.get_slot_id(paged_slot_id) + defp paged_slot_key({paged_slot_id, _}, _), do: Template.get_page(paged_slot_id) end diff --git a/lib/screens/v2/widget_instance/alert.ex b/lib/screens/v2/widget_instance/alert.ex index 76b6e5790..6fe8de557 100644 --- a/lib/screens/v2/widget_instance/alert.ex +++ b/lib/screens/v2/widget_instance/alert.ex @@ -103,7 +103,7 @@ defmodule Screens.V2.WidgetInstance.Alert do routes = if app_id === :gl_eink_v2 do # Get route pills for alert, including that on connecting GL branches - LocalizedAlert.informed_subway_routes(t) + LocalizedAlert.consolidated_informed_subway_routes(t) else # Get route pills for an alert, but only the routes that are at this stop LocalizedAlert.informed_routes_at_home_stop(t) diff --git a/lib/screens/v2/widget_instance/elevator_status.ex b/lib/screens/v2/widget_instance/elevator_status.ex index dee6f03ad..4652ff650 100644 --- a/lib/screens/v2/widget_instance/elevator_status.ex +++ b/lib/screens/v2/widget_instance/elevator_status.ex @@ -224,11 +224,12 @@ defmodule Screens.V2.WidgetInstance.ElevatorStatus do active and (alert_location === :upstream or alert_location === :downstream) end - defp sort_elsewhere(e1, _e2, %__MODULE__{location_context: %{stop_sequences: stop_sequences}}) do + defp sort_elsewhere(e1, _e2, %__MODULE__{location_context: location_context}) do stations = get_stations_from_entities(e1) flat_stop_sequences = - stop_sequences + location_context + |> LocationContext.stop_sequences() |> List.flatten() # NOTE: fix this, stop sequences never contain parent station IDs diff --git a/lib/screens/v2/widget_instance/reconstructed_alert.ex b/lib/screens/v2/widget_instance/reconstructed_alert.ex index 53b022a81..f4436171d 100644 --- a/lib/screens/v2/widget_instance/reconstructed_alert.ex +++ b/lib/screens/v2/widget_instance/reconstructed_alert.ex @@ -2,20 +2,28 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do @moduledoc false alias Screens.Alerts.Alert + alias Screens.Alerts.InformedEntity alias Screens.LocationContext + alias Screens.Routes.Route alias Screens.Stops.Stop + alias Screens.Util + alias Screens.V2.DisruptionDiagram alias Screens.V2.LocalizedAlert alias Screens.V2.WidgetInstance.ReconstructedAlert alias Screens.V2.WidgetInstance.Serializer.RoutePill alias ScreensConfig.Screen - alias ScreensConfig.V2.FreeTextLine + alias ScreensConfig.V2.{FreeText, FreeTextLine} + + require Logger defstruct screen: nil, alert: nil, now: nil, location_context: nil, - informed_stations_string: nil, - is_terminal_station: false + informed_stations: nil, + is_terminal_station: false, + # Full screen alert, whether that's a single or dual screen alert + is_full_screen: false @type stop_id :: String.t() @@ -26,36 +34,135 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do alert: Alert.t(), now: DateTime.t(), location_context: LocationContext.t(), - informed_stations_string: String.t(), - is_terminal_station: boolean() + informed_stations: list(String.t()), + is_terminal_station: boolean(), + is_full_screen: boolean() + } + + @type serialized_response :: + dual_screen_serialized_response() + | single_screen_serialized_response() + | flex_serialized_response() + + @type dual_screen_serialized_response :: %{ + optional(:other_closures) => list(String.t()), + issue: String.t(), + remedy: String.t(), + location: String.t() | FreeTextLine.t(), + cause: String.t(), + effect: :suspension | :shuttle | :station_closure, + updated_at: String.t(), + routes: list(RoutePill.t()) + } + + @type enriched_route :: %{ + optional(:headsign) => String.t(), + route_id: String.t(), + svg_name: String.t() + } + + @type single_screen_serialized_response :: %{ + # Unique to station closures + optional(:unaffected_routes) => list(enriched_route()), + optional(:location) => String.t() | nil, + optional(:remedy) => String.t() | nil, + optional(:stations) => list(String.t()), + # Unique to single screen alerts + optional(:endpoints) => list(String.t()), + # Unique to transfer station case + optional(:is_transfer_station) => boolean(), + # Weird extra field for fallback layout with special styling + optional(:remedy_bold) => String.t(), + issue: String.t() | list(String.t()) | nil, + cause: Alert.cause() | nil, + # List of SVG filenames + routes: list(enriched_route()), + effect: :suspension | :shuttle | :station_closure | :delay, + updated_at: String.t(), + region: :here | :boundary | :outside + } + + @type flex_serialized_response :: %{ + issue: String.t(), + remedy: String.t(), + location: String.t(), + cause: String.t(), + routes: list(map() | String.t()), + effect: :suspension | :shuttle | :station_closure | :delay | :severe_delay, + urgent: boolean() } @route_directions %{ "Blue" => ["Bowdoin", "Wonderland"], "Orange" => ["Forest Hills", "Oak Grove"], - "Red" => ["Ashmont/Braintree", "Alewife"], + "Red-Ashmont" => ["Ashmont", "Alewife"], + "Red-Braintree" => ["Braintree", "Alewife"], + "Red" => ["Ashmont & Braintree", "Alewife"], "Green-B" => ["Boston College", "Government Center"], "Green-C" => ["Cleveland Circle", "Government Center"], - "Green-D" => ["Riverside", "North Station"], - "Green-E" => ["Heath Street", "Union Square"] + "Green-D" => ["Riverside", "Union Square"], + "Green-E" => ["Heath Street", "Medford/Tufts"], + "Green-trunk" => ["Copley & West", "North Station & North"] } - @green_line_branches ["Green-B", "Green-C", "Green-D", "Green-E"] + @headsign_svg_map %{ + "Bowdoin" => "bl-bowdoin", + "Wonderland" => "bl-wonderland", + "Government Center" => "gl-govt-center", + "Copley & West" => "gl-copley-west", + "North Station & North" => "gl-north-station-north", + "Boston College" => "glb-boston-college", + "Cleveland Circle" => "glc-cleveland-cir", + "Riverside" => "gld-riverside", + "Union Square" => "gld-union-sq", + "Heath Street" => "gle-heath-st", + "Medford/Tufts" => "gle-medford-tufts", + "Forest Hills" => "ol-forest-hills", + "Oak Grove" => "ol-oak-grove", + "Alewife" => "rl-alewife", + "Ashmont" => "rl-ashmont", + "Braintree" => "rl-braintree" + } - # Using hd/1 because we know that only single line stations use this function. - defp get_destination(%{alert: alert} = t, location) do - informed_entities = Alert.informed_entities(alert) + @green_line_branches ["Green-B", "Green-C", "Green-D", "Green-E"] - {direction_id, route_id} = + defp get_destination( + %__MODULE__{alert: alert} = t, + location, + route_id \\ nil + ) do + informed_entities = + alert + |> Alert.informed_entities() + |> Enum.filter(fn entity -> + (InformedEntity.parent_station?(entity) or is_nil(entity.stop)) and + (is_nil(route_id) or String.starts_with?(entity.route, route_id)) + end) + + # Consolidate the list of entities into their direction from current station + # and their affiliated route id + list_of_directions_and_routes = informed_entities - |> Enum.map(fn %{direction_id: direction_id, route: route} -> {direction_id, route} end) + |> Enum.map(fn entity -> get_direction_and_route_from_entity(entity, location) end) |> Enum.uniq() - |> hd() + + {direction_id, route_id} = + if length(list_of_directions_and_routes) == 1 do + hd(list_of_directions_and_routes) + # If there are multiple route ids in that informed entities list, we're on a branch + else + direction_id = + list_of_directions_and_routes + |> hd() + |> elem(0) + + {direction_id, "Green-trunk"} + end cond do # When the alert is non-directional but the station is at the boundary: # direction_id will be nil, but we still want to show the alert impacts one direction only - is_nil(direction_id) and location == :boundary -> + is_nil(direction_id) and location in [:boundary_downstream, :boundary_upstream] -> LocalizedAlert.get_headsign_from_informed_entities(t) # When the alert is non-directional and the station is outside the alert range @@ -70,212 +177,557 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do end end - defp get_route_pills(routes) do - routes - |> Enum.group_by(fn - "Green" <> _ -> "Green" - route -> route - end) + # Given an entity and the directionality of the alert from the home stop, + # return a tuple with the affected direction_id and route_id + + # If the route is red and the alert is downstream, we have to figure out whether the alert + # only affects one branch or both + defp get_direction_and_route_from_entity( + %{direction_id: nil, route: "Red", stop: stop_id}, + location + ) + when stop_id != nil and location in [:downstream, :boundary_downstream] do + cond do + Stop.on_ashmont_branch?(stop_id) -> + {0, "Red-Ashmont"} + + Stop.on_braintree_branch?(stop_id) -> + {0, "Red-Braintree"} + + true -> + {0, "Red"} + end + end + + # Same with RL upstream alerts + defp get_direction_and_route_from_entity( + %{direction_id: nil, route: "Red", stop: stop_id}, + location + ) + when stop_id != nil and location in [:upstream, :boundary_upstream] do + cond do + Stop.on_ashmont_branch?(stop_id) -> + {1, "Red-Ashmont"} + + Stop.on_braintree_branch?(stop_id) -> + {1, "Red-Braintree"} + + true -> + {1, "Red"} + end + end + + defp get_direction_and_route_from_entity(%{direction_id: nil, route: route}, location) + when location in [:downstream, :boundary_downstream], + do: {0, route} + + defp get_direction_and_route_from_entity(%{direction_id: nil, route: route}, location) + when location in [:upstream, :boundary_upstream], + do: {1, route} + + defp get_direction_and_route_from_entity(%{direction_id: direction_id, route: route}, _), + do: {direction_id, route} + + defp get_route_pills(t, location \\ nil) + + defp get_route_pills(t, nil) do + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) + + affected_routes + |> Enum.group_by(&get_line/1) |> Enum.map( - &RoutePill.serialize_route_for_reconstructed_alert(&1, %{large: length(routes) == 1}) + &RoutePill.serialize_route_for_reconstructed_alert(&1, %{ + large: length(affected_routes) == 1 + }) ) end - def takeover_alert?( - %{ - screen: %Screen{app_id: :pre_fare_v2}, - is_terminal_station: is_terminal_station, - alert: alert - } = t - ) do + defp get_route_pills(%__MODULE__{} = t, location) do + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) + routes_at_stop = LocalizedAlert.active_routes_at_stop(t) + + affected_routes + # Filter alert-affected routes by which routes are at the current stop + # If a green-branch is the affected route, we can generalize it to just "Green-" + # because our prefare screens will be on the trunk. Any GL disruption will be + # downstream of a GL trunk station. + |> Enum.filter(fn + "Green" <> _ -> Enum.find(routes_at_stop, &String.starts_with?(&1, "Green")) + route -> route in routes_at_stop + end) + |> Enum.flat_map(fn + route_id -> + # Boundary alerts shouldn't have headsign in the banner + headsign = + unless get_region_from_location(location) === :boundary do + get_destination(t, location, route_id) + end + + build_pills_from_headsign(route_id, headsign) + end) + |> Enum.uniq() + end + + defp build_pills_from_headsign(route_id, nil) do + [ + %{ + route_id: get_line(route_id), + svg_name: format_short_route_pill(route_id) + } + ] + end + + # Split "Ashmont & Braintree" out into two route pills + defp build_pills_from_headsign(route_id, "Ashmont & Braintree") do + Enum.map(["Ashmont", "Braintree"], fn dest -> + %{ + route_id: route_id, + svg_name: format_for_svg_name(dest), + headsign: dest + } + end) + end + + # If headsign is for the trunk, use "Green" as route_id + defp build_pills_from_headsign(_route_id, headsign) + when headsign in ["North Station & North", "Copley & West"] do + [ + %{ + route_id: "Green", + svg_name: format_for_svg_name(headsign), + headsign: headsign + } + ] + end + + defp build_pills_from_headsign(route_id, headsign) do + [ + %{ + route_id: route_id, + svg_name: format_for_svg_name(headsign), + headsign: headsign + } + ] + end + + defp get_line("Green" <> _), do: "Green" + defp get_line(route_id), do: route_id + + defp format_for_svg_name(headsign), do: Map.get(@headsign_svg_map, headsign) + + defp format_cause(:unknown), do: nil + defp format_cause(cause), do: cause |> to_string() |> String.replace("_", " ") + + defp format_short_route_pill("Green-" <> branch), do: "gl-#{String.downcase(branch)}" + + defp format_short_route_pill(route_id), + do: route_id |> String.first() |> String.downcase() |> Kernel.<>("l") + + # Alert subheaders should not wrap in the middle of a station name + # so we have to use FreeTextLines to prevent the wrapping. + # This function takes a list of proper noun strings and + # returns a list of FreeTextLines with "nowrap" applied + @spec format_station_name_list([String.t()]) :: list(FreeText.t()) + defp format_station_name_list([string]), do: [%{format: :nowrap, text: "#{string}"}] + + defp format_station_name_list([s1, s2]), + do: [ + %{format: :nowrap, text: "#{s1}"}, + " & ", + %{format: :nowrap, text: "#{s2}"} + ] + + defp format_station_name_list(list) do + list + |> List.update_at(-1, &" & #{&1}") + |> Enum.join(", #") + |> String.split("#") + |> Enum.map(fn string -> %{format: :nowrap, text: string} end) + |> Enum.intersperse("") + end + + defp get_region_from_location(:inside), do: :here + + defp get_region_from_location(location) + when location in [:boundary_upstream, :boundary_downstream], + do: :boundary + + defp get_region_from_location(_location), do: :outside + + defp get_cause(:unknown), do: nil + defp get_cause(cause), do: cause + + def dual_screen_alert?(%__MODULE__{is_full_screen: false}), do: false + + def dual_screen_alert?(%__MODULE__{is_terminal_station: is_terminal_station, alert: alert} = t) do Alert.effect(alert) in [:station_closure, :suspension, :shuttle] and LocalizedAlert.location(t, is_terminal_station) == :inside and - LocalizedAlert.informs_all_active_routes_at_home_stop?(t) + LocalizedAlert.informs_all_active_routes_at_home_stop?(t) and + (is_nil(Alert.direction_id(t.alert)) or is_terminal_station) end - defp serialize_takeover_alert( - %__MODULE__{ - alert: %Alert{effect: :suspension, cause: cause} = alert - } = t - ) do + @spec serialize_dual_screen_alert(t()) :: dual_screen_serialized_response() + defp serialize_dual_screen_alert(t) + + # Two screen alert, suspension + defp serialize_dual_screen_alert(%__MODULE__{alert: %Alert{effect: :suspension} = alert} = t) do + %{alert: %{cause: cause, updated_at: updated_at}, now: now} = t informed_entities = Alert.informed_entities(alert) - affected_routes = LocalizedAlert.informed_subway_routes(t) - cause_text = cause |> Alert.get_cause_string() |> String.capitalize() - - location_text = get_endpoints(informed_entities, hd(affected_routes)) - - issue = %FreeTextLine{ - icon: nil, - text: - ["No"] ++ - (affected_routes - |> Enum.map(fn route -> - %{ - route: - route - |> String.replace("-", "_") - |> String.downcase() - } - end) - |> Enum.to_list()) ++ - ["trains"] - } + + route_id = + case LocalizedAlert.consolidated_informed_subway_routes(t) do + ["Green" <> _] -> "Green" + [route_id] -> route_id + end + + endpoints = get_endpoints(informed_entities, route_id, t.location_context.home_stop) %{ - issue: FreeTextLine.to_json(issue), + issue: "No trains", remedy: "Seek alternate route", - location: location_text, - cause: cause_text, - routes: get_route_pills(affected_routes), + location: "No #{route_id} Line trains #{format_endpoint_string(endpoints)}", + endpoints: endpoints, + cause: format_cause(cause), + routes: get_route_pills(t), effect: :suspension, - urgent: true + updated_at: format_updated_at(updated_at, now) } end - defp serialize_takeover_alert( - %__MODULE__{ - alert: %Alert{effect: :shuttle, cause: cause} = alert - } = t - ) do + # Two screen alert, shuttle + defp serialize_dual_screen_alert(%__MODULE__{alert: %Alert{effect: :shuttle} = alert} = t) do + %{alert: %{cause: cause, updated_at: updated_at}, now: now} = t informed_entities = Alert.informed_entities(alert) - affected_routes = LocalizedAlert.informed_subway_routes(t) - cause_text = cause |> Alert.get_cause_string() |> String.capitalize() - - location_text = get_endpoints(informed_entities, hd(affected_routes)) - - issue = %FreeTextLine{ - icon: nil, - text: - ["No"] ++ - (affected_routes - |> Enum.map(fn route -> - %{ - route: - route - |> String.replace("-", "_") - |> String.downcase() - } - end) - |> Enum.to_list()) ++ - ["trains"] - } + + route_id = + case LocalizedAlert.consolidated_informed_subway_routes(t) do + ["Green" <> _] -> "Green" + [route_id] -> route_id + end + + endpoints = get_endpoints(informed_entities, route_id, t.location_context.home_stop) %{ - issue: FreeTextLine.to_json(issue), + issue: "No trains", remedy: "Use shuttle bus", - location: location_text, - cause: cause_text, - routes: get_route_pills(affected_routes), + location: + "Shuttle buses replace #{route_id} Line trains #{format_endpoint_string(endpoints)}", + endpoints: endpoints, + cause: format_cause(cause), + routes: get_route_pills(t), effect: :shuttle, - urgent: true + updated_at: format_updated_at(updated_at, now) } end - defp serialize_takeover_alert( - %__MODULE__{alert: %Alert{effect: :station_closure, cause: cause}} = t - ) do - affected_routes = LocalizedAlert.informed_subway_routes(t) - cause_text = cause |> Alert.get_cause_string() |> String.capitalize() + # Two screen alert, station closure + defp serialize_dual_screen_alert(%__MODULE__{alert: %Alert{effect: :station_closure}} = t) do + %{ + alert: %{cause: cause, updated_at: updated_at}, + now: now, + location_context: %{home_stop_name: stop_name}, + informed_stations: informed_stations + } = t + + # Alert subheaders should not wrap in the middle of a station name + # so we have to use FreeTextLines to prevent the wrapping + informed_stations_free_text = format_station_name_list(informed_stations) + + location_text = + case LocalizedAlert.consolidated_informed_subway_routes(t) do + [route_id] -> + %FreeTextLine{ + icon: nil, + text: ["#{route_id} Line trains skip "] ++ informed_stations_free_text + } + + [route_id1, route_id2] -> + %FreeTextLine{ + icon: nil, + text: + ["The #{route_id1} Line and #{route_id2} Line skip "] ++ informed_stations_free_text + } + end + + other_closures = List.delete(informed_stations, stop_name) %{ - issue: "Station Closed", + issue: "Station closed", remedy: "Seek alternate route", - location: "", - cause: cause_text, - routes: get_route_pills(affected_routes), + location: location_text, + cause: format_cause(cause), + routes: get_route_pills(t), effect: :station_closure, - urgent: true + updated_at: format_updated_at(updated_at, now), + other_closures: other_closures } end - defp serialize_inside_flex_alert( - %__MODULE__{ - alert: %Alert{ - effect: :suspension, - cause: cause - } - } = t + # When an alert violates our assumptions and we're unable to make a disruption diagram + # we show this fallback format for dual / single screen alerts + @spec serialize_dual_screen_fallback_alert(t()) :: dual_screen_serialized_response() + defp serialize_dual_screen_fallback_alert(%__MODULE__{alert: alert, now: now} = t) do + %{ + issue: if(alert.effect == :station_closure, do: "Station closed", else: "No trains"), + remedy: if(alert.effect == :shuttle, do: "Use shuttle bus", else: "Seek alternate route"), + location: alert.header, + cause: format_cause(alert.cause), + routes: get_route_pills(t), + effect: alert.effect, + updated_at: format_updated_at(alert.updated_at, now) + } + end + + @spec serialize_single_screen_fallback_alert(t(), LocalizedAlert.location()) :: + single_screen_serialized_response() + defp serialize_single_screen_fallback_alert(%__MODULE__{alert: alert, now: now} = t, location) do + %{ + issue: nil, + remedy: nil, + remedy_bold: alert.header, + location: nil, + cause: format_cause(alert.cause), + routes: get_route_pills(t, location), + effect: alert.effect, + updated_at: format_updated_at(alert.updated_at, now), + region: get_region_from_location(location) + } + end + + @spec serialize_single_screen_alert(t(), LocalizedAlert.location()) :: + single_screen_serialized_response() + defp serialize_single_screen_alert(t, location) + + defp serialize_single_screen_alert( + %__MODULE__{alert: %Alert{effect: :suspension} = alert} = t, + location ) do - affected_routes = LocalizedAlert.informed_subway_routes(t) - cause_text = Alert.get_cause_string(cause) + %{alert: %{cause: cause, updated_at: updated_at}, now: now} = t + informed_entities = Alert.informed_entities(alert) + + route_id = + case LocalizedAlert.consolidated_informed_subway_routes(t) do + ["Green" <> _] -> "Green" + [route_id] -> route_id + end + + endpoints = get_endpoints(informed_entities, route_id, t.location_context.home_stop) + destination = get_destination(t, location) + + {issue, location_text} = + if location in [:downstream, :upstream] do + {"No trains", nil} + else + endpoint_text = format_endpoint_string(endpoints) + + location_text = + if is_nil(endpoint_text), do: nil, else: "No #{route_id} Line trains #{endpoint_text}" + + issue = + cond do + # Here + location == :inside -> + "No #{route_id} Line trains" + + is_nil(destination) -> + "No trains" + + # Boundary + true -> + "No trains to #{destination}" + end + + {issue, location_text} + end %{ - issue: "No trains", + issue: issue, remedy: "Seek alternate route", - location: "", - cause: cause_text, - routes: get_route_pills(affected_routes), + location: location_text, + cause: get_cause(cause), + routes: get_route_pills(t, location), effect: :suspension, - urgent: true + updated_at: format_updated_at(updated_at, now), + region: get_region_from_location(location), + endpoints: endpoints, + is_transfer_station: location == :inside } end - defp serialize_inside_flex_alert(%__MODULE__{alert: %Alert{effect: :shuttle, cause: cause}} = t) do - affected_routes = LocalizedAlert.informed_subway_routes(t) - cause_text = Alert.get_cause_string(cause) + defp serialize_single_screen_alert( + %__MODULE__{alert: %Alert{effect: :shuttle} = alert} = t, + location + ) do + %{alert: %{cause: cause, updated_at: updated_at}, now: now} = t + informed_entities = Alert.informed_entities(alert) + + route_id = + case LocalizedAlert.consolidated_informed_subway_routes(t) do + ["Green" <> _] -> "Green" + [route_id] -> route_id + end + + endpoints = get_endpoints(informed_entities, route_id, t.location_context.home_stop) + destination = get_destination(t, location) + + {issue, location_text, remedy} = + if location in [:downstream, :upstream] do + {"No trains", nil, "Shuttle buses available"} + else + endpoint_text = format_endpoint_string(endpoints) + location_text = if is_nil(endpoint_text), do: nil, else: "Shuttle buses #{endpoint_text}" + + issue = + cond do + location == :inside -> + "No #{route_id} Line trains" + + is_nil(destination) -> + "No trains" + + true -> + "No trains to #{destination}" + end + + {issue, location_text, "Use shuttle bus"} + end %{ - issue: "No trains", - remedy: "Use shuttle bus", - location: "", - cause: cause_text, - routes: get_route_pills(affected_routes), + issue: issue, + remedy: remedy, + location: location_text, + cause: get_cause(cause), + routes: get_route_pills(t, location), effect: :shuttle, - urgent: true + updated_at: format_updated_at(updated_at, now), + region: get_region_from_location(location), + endpoints: endpoints, + is_transfer_station: location == :inside } end - defp serialize_inside_flex_alert( - %__MODULE__{ - alert: %Alert{effect: :station_closure, cause: cause} - } = t + # Station closure for 1 line at a multi-line station + defp serialize_single_screen_alert( + %__MODULE__{alert: %Alert{effect: :station_closure}} = t, + :inside ) do - affected_routes = LocalizedAlert.informed_subway_routes(t) - cause_text = Alert.get_cause_string(cause) + %{alert: %{cause: cause, updated_at: updated_at}, now: now} = t + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) + routes_at_stop = LocalizedAlert.active_routes_at_stop(t) + + unaffected_routes = + if "Green" in affected_routes do + Enum.reject(routes_at_stop, &String.starts_with?(&1, "Green-")) + else + routes = Enum.into(routes_at_stop, []) -- affected_routes - line = - case affected_routes do - ["Green-" <> branch | _] -> "Green Line #{branch} branch" - [affected_line | _] -> "#{affected_line} line" + if Enum.any?(routes, &String.starts_with?(&1, "Green-")) do + routes + |> Enum.reject(&String.starts_with?(&1, "Green-")) + |> Enum.concat(["Green"]) + else + routes + end end %{ - issue: "#{line} platform closed", + issue: nil, + unaffected_routes: + Enum.flat_map(unaffected_routes, fn route -> build_pills_from_headsign(route, nil) end), + cause: get_cause(cause), + routes: get_route_pills(t, :inside), + effect: :station_closure, + updated_at: format_updated_at(updated_at, now), + region: :here + } + end + + # Downstream closure + defp serialize_single_screen_alert( + %__MODULE__{alert: %Alert{effect: :station_closure}} = t, + location + ) do + %{ + alert: %{cause: cause, updated_at: updated_at}, + now: now, + informed_stations: informed_stations + } = t + + informed_stations_string = Util.format_name_list_to_string(informed_stations) + + %{ + issue: "Trains skip #{informed_stations_string}", remedy: "Seek alternate route", - location: "", - cause: cause_text, - routes: get_route_pills(affected_routes), + cause: get_cause(cause), + routes: get_route_pills(t, location), effect: :station_closure, - urgent: true + updated_at: format_updated_at(updated_at, now), + region: get_region_from_location(location), + stations: informed_stations } end + defp serialize_single_screen_alert( + %__MODULE__{alert: %Alert{effect: :delay}} = t, + location + ) do + %{ + alert: %{cause: cause, updated_at: updated_at, severity: severity, header: header}, + now: now + } = t + + {delay_description, delay_minutes} = Alert.interpret_severity(severity) + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) + + duration_text = + case delay_description do + :up_to -> "up to #{delay_minutes} minutes" + :more_than -> "over #{delay_minutes} minutes" + end + + routes = + if length(affected_routes) > 1 do + [] + else + get_route_pills(t, location) + end + + %{ + issue: "Trains may be delayed #{duration_text}", + remedy: header, + cause: get_cause(cause), + routes: routes, + effect: :delay, + updated_at: format_updated_at(updated_at, now), + region: get_region_from_location(location) + } + end + + @spec serialize_inside_flex_alert(t()) :: flex_serialized_response() + defp serialize_inside_flex_alert(t) + defp serialize_inside_flex_alert( - %__MODULE__{ - alert: %Alert{effect: :delay, severity: severity, header: header} - } = t + %__MODULE__{alert: %Alert{effect: :delay, severity: severity}} = t ) when severity > 3 and severity < 7 do - affected_routes = LocalizedAlert.informed_subway_routes(t) + %{alert: %{header: header}} = t %{ issue: header, remedy: "", location: "", cause: "", - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :delay, urgent: false } end defp serialize_inside_flex_alert( - %__MODULE__{ - alert: %Alert{effect: :delay, cause: cause, severity: severity} - } = t + %__MODULE__{alert: %Alert{effect: :delay, severity: severity}} = t ) when severity >= 7 do - affected_routes = LocalizedAlert.informed_subway_routes(t) + %{alert: %{cause: cause}} = t cause_text = Alert.get_cause_string(cause) {delay_description, delay_minutes} = Alert.interpret_severity(severity) destination = get_destination(t, :inside) @@ -300,26 +752,18 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "", location: "", cause: cause_text, - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :severe_delay, urgent: true } end - defp serialize_inside_alert(%__MODULE__{} = t) do - if takeover_alert?(t) do - serialize_takeover_alert(t) - else - serialize_inside_flex_alert(t) - end - end + @spec serialize_boundary_alert(t(), any()) :: flex_serialized_response() + defp serialize_boundary_alert(t, location) - defp serialize_boundary_alert( - %__MODULE__{ - alert: %Alert{effect: :suspension, cause: cause, header: header} - } = t - ) do - affected_routes = LocalizedAlert.informed_subway_routes(t) + defp serialize_boundary_alert(%__MODULE__{alert: %Alert{effect: :suspension}} = t, location) do + %{alert: %{cause: cause, header: header}} = t + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) if length(affected_routes) > 1 do %{ @@ -327,12 +771,12 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "Seek alternate route", location: "", cause: "", - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :suspension, urgent: true } else - destination = get_destination(t, :boundary) + destination = get_destination(t, location) cause_text = Alert.get_cause_string(cause) issue = @@ -347,19 +791,16 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "Seek alternate route", location: "", cause: cause_text, - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :suspension, urgent: true } end end - defp serialize_boundary_alert( - %__MODULE__{ - alert: %Alert{effect: :shuttle, cause: cause, header: header} - } = t - ) do - affected_routes = LocalizedAlert.informed_subway_routes(t) + defp serialize_boundary_alert(%__MODULE__{alert: %Alert{effect: :shuttle}} = t, location) do + %{alert: %{cause: cause, header: header}} = t + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) if length(affected_routes) > 1 do %{ @@ -367,12 +808,12 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "Use shuttle bus", location: "", cause: "", - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :shuttle, urgent: true } else - destination = get_destination(t, :boundary) + destination = get_destination(t, location) cause_text = Alert.get_cause_string(cause) issue = @@ -387,41 +828,41 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "Use shuttle bus", location: "", cause: cause_text, - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :shuttle, urgent: true } end end - defp serialize_boundary_alert(%__MODULE__{alert: %Alert{effect: :station_closure}}), do: nil + defp serialize_boundary_alert(%__MODULE__{alert: %Alert{effect: :station_closure}}, _location), + do: nil defp serialize_boundary_alert( - %__MODULE__{ - alert: %Alert{effect: :delay, severity: severity, header: header} - } = t + %__MODULE__{alert: %Alert{effect: :delay, severity: severity}} = t, + _location ) when severity > 3 and severity < 7 do - affected_routes = LocalizedAlert.informed_subway_routes(t) + %{alert: %{header: header}} = t %{ issue: header, remedy: "", location: "", cause: "", - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :delay, urgent: false } end defp serialize_boundary_alert( - %__MODULE__{ - alert: %Alert{effect: :delay, cause: cause, severity: severity, header: header} - } = t + %__MODULE__{alert: %Alert{effect: :delay, severity: severity}} = t, + location ) when severity >= 7 do - affected_routes = LocalizedAlert.informed_subway_routes(t) + %{alert: %{cause: cause, header: header}} = t + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) if length(affected_routes) > 1 do %{ @@ -429,14 +870,14 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "", location: "", cause: "", - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :severe_delay, urgent: true } else cause_text = Alert.get_cause_string(cause) {delay_description, delay_minutes} = Alert.interpret_severity(severity) - destination = get_destination(t, :boundary) + destination = get_destination(t, location) duration_text = case delay_description do @@ -456,21 +897,25 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "", location: "", cause: cause_text, - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :severe_delay, urgent: true } end end - defp serialize_boundary_alert(%__MODULE__{alert: %Alert{effect: :delay}}), do: nil + defp serialize_boundary_alert(%__MODULE__{alert: %Alert{effect: :delay}}, _location), do: nil + + @spec serialize_outside_alert(t(), any()) :: flex_serialized_response() + defp serialize_outside_alert(t, location) defp serialize_outside_alert( - %__MODULE__{alert: %Alert{effect: :suspension, cause: cause, header: header} = alert} = t + %__MODULE__{alert: %Alert{effect: :suspension} = alert} = t, + location ) do + %{alert: %{cause: cause, header: header}} = t informed_entities = Alert.informed_entities(alert) - - affected_routes = LocalizedAlert.informed_subway_routes(t) + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) if length(affected_routes) > 1 do %{ @@ -478,20 +923,24 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "Seek alternate route", location: "", cause: "", - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :suspension, urgent: false } else - destination = get_destination(t, :outside) + direction_id = Alert.direction_id(alert) cause_text = Alert.get_cause_string(cause) - location_text = get_endpoints(informed_entities, hd(affected_routes)) + + location_text = + informed_entities + |> get_endpoints(hd(affected_routes), t.location_context.home_stop) + |> format_endpoint_string() issue = - if is_nil(destination) do + if is_nil(direction_id) do "No trains" else - "No #{destination} trains" + "No #{get_destination(t, location)} trains" end %{ @@ -499,19 +948,17 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "Seek alternate route", location: location_text, cause: cause_text, - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :suspension, urgent: false } end end - defp serialize_outside_alert( - %__MODULE__{alert: %Alert{effect: :shuttle, cause: cause, header: header} = alert} = t - ) do + defp serialize_outside_alert(%__MODULE__{alert: %Alert{effect: :shuttle} = alert} = t, location) do + %{alert: %{cause: cause, header: header}} = t informed_entities = Alert.informed_entities(alert) - - affected_routes = LocalizedAlert.informed_subway_routes(t) + affected_routes = LocalizedAlert.consolidated_informed_subway_routes(t) if length(affected_routes) > 1 do %{ @@ -519,20 +966,24 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "Use shuttle bus", location: "", cause: "", - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :suspension, urgent: false } else - destination = get_destination(t, :outside) + direction_id = Alert.direction_id(alert) cause_text = Alert.get_cause_string(cause) - location_text = get_endpoints(informed_entities, List.first(affected_routes)) + + location_text = + informed_entities + |> get_endpoints(hd(affected_routes), t.location_context.home_stop) + |> format_endpoint_string() issue = - if is_nil(destination) do + if is_nil(direction_id) do "No trains" else - "No #{destination} trains" + "No #{get_destination(t, location)} trains" end %{ @@ -540,7 +991,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do remedy: "Use shuttle bus", location: location_text, cause: cause_text, - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :shuttle, urgent: false } @@ -548,47 +999,70 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do end defp serialize_outside_alert( - %__MODULE__{ - alert: %Alert{effect: :station_closure, cause: cause}, - informed_stations_string: informed_stations_string - } = t + %__MODULE__{alert: %Alert{effect: :station_closure}} = t, + _location ) do - affected_routes = LocalizedAlert.informed_subway_routes(t) - + %{alert: %{cause: cause}, informed_stations: informed_stations} = t cause_text = Alert.get_cause_string(cause) + informed_stations_string = Util.format_name_list_to_string(informed_stations) + %{ issue: "Trains will bypass #{informed_stations_string}", remedy: "Seek alternate route", location: "", cause: cause_text, - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :station_closure, urgent: false } end - defp serialize_outside_alert(%__MODULE__{alert: %Alert{effect: :delay, header: header}} = t) do - affected_routes = LocalizedAlert.informed_subway_routes(t) + defp serialize_outside_alert( + %__MODULE__{alert: %Alert{effect: :delay}} = t, + _location + ) do + %{alert: %{header: header}} = t %{ issue: header, remedy: "", location: "", cause: "", - routes: get_route_pills(affected_routes), + routes: get_route_pills(t), effect: :delay, urgent: false } end - def get_endpoints(ie, "Green") do + defp format_updated_at(updated_at, now) do + shifted_updated_at = DateTime.shift_zone!(updated_at, "America/New_York") + + if Date.compare(updated_at, now) == :lt do + Timex.format!(shifted_updated_at, "{M}/{D}/{YY}") + else + Timex.format!(shifted_updated_at, "{WDfull}, {h12}:{m} {am}") + end + end + + defp abbreviate_station_name("Massachusetts Avenue"), do: "Mass Ave" + defp abbreviate_station_name(full_name), do: full_name + + @spec get_endpoints(list(Alert.informed_entity()), Route.id(), Stop.id()) :: + {String.t(), String.t()} | nil + defp get_endpoints(informed_entities, route_id, home_stop) do + with {left_endpoint, right_endpoint} <- do_get_endpoints(informed_entities, route_id) do + orient_endpoints({left_endpoint, right_endpoint}, informed_entities, route_id, home_stop) + end + end + + def do_get_endpoints(ie, "Green") do Enum.find_value(@green_line_branches, fn branch -> - get_endpoints(ie, branch) + do_get_endpoints(ie, branch) end) end - def get_endpoints(informed_entities, route_id) do + def do_get_endpoints(informed_entities, route_id) do case Stop.get_stop_sequence(informed_entities, route_id) do nil -> nil @@ -606,47 +1080,139 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do {min_full_name, _min_abbreviated_name} = min_station_name {max_full_name, _max_abbreviated_name} = max_station_name - if min_full_name == max_full_name do - "at #{min_full_name}" - else - "between #{min_full_name} and #{max_full_name}" - end + {abbreviate_station_name(min_full_name), abbreviate_station_name(max_full_name)} + end + end + + # In certain cases, we want to describe an alert's endpoints in the opposite direction, + # i.e. direction ID 1 instead of 0, so we need to flip the endpoints tuple before it gets formatted to string. + # + # Endpoints should be passed to this function in the order of direction ID 0. + # For example, orient_endpoints({"place-wondl", "place-gover"}, ...) and not orient_endpoints({"place-gover", "place-wondl"}, ...) + + # All Blue Line alerts + defp orient_endpoints({left_endpoint, right_endpoint}, _informed_entities, "Blue", _home_stop) do + {right_endpoint, left_endpoint} + end + + # All Green Line alerts *except* those where the alert's informed stops ++ the screen's home stop satisfy these conditions: + # - includes at least one GLX stop + # - does not include any stops west of Copley + defp orient_endpoints({left_endpoint, right_endpoint}, informed_entities, route_id, home_stop) + when route_id in ["Green" | @green_line_branches] do + if glx_oriented_alert?(informed_entities, home_stop) do + {left_endpoint, right_endpoint} + else + {right_endpoint, left_endpoint} end end - def serialize(%__MODULE__{is_terminal_station: is_terminal_station} = t) do + # Otherwise, we don't need to flip the endpoints. + defp orient_endpoints({left_endpoint, right_endpoint}, _ies, _route_id, _home_stop) do + {left_endpoint, right_endpoint} + end + + defp glx_oriented_alert?(informed_entities, home_stop) do + parent_station_ies = Enum.filter(informed_entities, &InformedEntity.parent_station?/1) + + # It's ok if the home stop is duplicated in this list due to also being informed by the alert. + relevant_parent_stations = [home_stop | Enum.map(parent_station_ies, & &1.stop)] + + includes_glx = Enum.any?(relevant_parent_stations, &Stop.on_glx?/1) + + stops_west_of_copley = Stop.get_gl_stops_west_of_copley() + includes_west_of_copley = Enum.any?(relevant_parent_stations, &(&1 in stops_west_of_copley)) + + includes_glx and not includes_west_of_copley + end + + def format_endpoint_string(nil), do: nil + + def format_endpoint_string({station, station}) do + "at #{station}" + end + + def format_endpoint_string({min_station, max_station}) do + "between #{min_station} and #{max_station}" + end + + def serialize(widget, log_fn \\ &Logger.warn/1) + + def serialize(%__MODULE__{is_full_screen: true, alert: %Alert{effect: effect}} = t, log_fn) do + diagram_data = + case DisruptionDiagram.serialize(t) do + {:ok, serialized_diagram} -> + %{disruption_diagram: serialized_diagram} + + {:error, reason} -> + log_fn.( + "[disruption diagram error] alert_id=#{t.alert.id} home_stop=#{t.location_context.home_stop} #{reason}" + ) + + %{} + end + + main_data = pick_layout_serializer(t, diagram_data, effect, dual_screen_alert?(t)) + + Map.merge(main_data, diagram_data) + end + + def serialize(%__MODULE__{is_terminal_station: is_terminal_station} = t, _log_fn) do case LocalizedAlert.location(t, is_terminal_station) do :inside -> - t |> serialize_inside_alert() |> Map.put(:region, :inside) + t |> serialize_inside_flex_alert() |> Map.put(:region, :inside) location when location in [:boundary_upstream, :boundary_downstream] -> - t |> serialize_boundary_alert() |> Map.put(:region, :boundary) + t |> serialize_boundary_alert(location) |> Map.put(:region, :boundary) location when location in [:downstream, :upstream] -> - t |> serialize_outside_alert() |> Map.put(:region, :outside) + t |> serialize_outside_alert(location) |> Map.put(:region, :outside) end end + def pick_layout_serializer(t, diagram, effect, true) when diagram == %{} and effect != :delay, + do: serialize_dual_screen_fallback_alert(t) + + def pick_layout_serializer(t, diagram, effect, false) + when diagram == %{} and effect != :delay do + location = LocalizedAlert.location(t) + serialize_single_screen_fallback_alert(t, location) + end + + def pick_layout_serializer(t, _, _, true), do: serialize_dual_screen_alert(t) + + def pick_layout_serializer(t, _, _, _) do + location = LocalizedAlert.location(t) + serialize_single_screen_alert(t, location) + end + + def audio_sort_key(%__MODULE__{is_full_screen: true}), do: [1] + def audio_sort_key(%__MODULE__{} = t) do case serialize(t) do - %{urgent: true} -> [2] - %{effect: effect} when effect in [:delay] -> [2, 2] - _ -> [2, 1] + %{urgent: true} -> [1] + %{effect: effect} when effect in [:delay] -> [1, 1] + _ -> [1, 2] end end - def priority(%__MODULE__{} = t) do - if takeover_alert?(t), do: [1], else: [3] - end + def priority(%__MODULE__{is_full_screen: true}), do: [1] + def priority(_t), do: [3] + + def slot_names(%__MODULE__{is_full_screen: false}), do: [:large] def slot_names(%__MODULE__{} = t) do - if takeover_alert?(t), do: [:full_body], else: [:large] + if dual_screen_alert?(t), + do: [:full_body], + else: [:paged_main_content_left] end + def widget_type(%__MODULE__{is_full_screen: false}), do: :reconstructed_large_alert + def widget_type(%__MODULE__{} = t) do - if takeover_alert?(t), + if dual_screen_alert?(t), do: :reconstructed_takeover, - else: :reconstructed_large_alert + else: :single_screen_alert end def alert_ids(%__MODULE__{} = t), do: [t.alert.id] @@ -672,7 +1238,13 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlert do def audio_serialize(t), do: ReconstructedAlert.serialize(t) def audio_sort_key(t), do: ReconstructedAlert.audio_sort_key(t) def audio_valid_candidate?(t), do: ReconstructedAlert.valid_candidate?(t) - def audio_view(_instance), do: ScreensWeb.V2.Audio.ReconstructedAlertView + + def audio_view(t), + do: + if(ReconstructedAlert.widget_type(t) == :reconstructed_large_alert, + do: ScreensWeb.V2.Audio.ReconstructedAlertView, + else: ScreensWeb.V2.Audio.ReconstructedAlertSingleScreenView + ) end defimpl Screens.V2.AlertsWidget do diff --git a/lib/screens/v2/widget_instance/subway_status.ex b/lib/screens/v2/widget_instance/subway_status.ex index ab1f43385..a50089f90 100644 --- a/lib/screens/v2/widget_instance/subway_status.ex +++ b/lib/screens/v2/widget_instance/subway_status.ex @@ -2,6 +2,7 @@ defmodule Screens.V2.WidgetInstance.SubwayStatus do @moduledoc false alias Screens.Alerts.Alert + alias Screens.Alerts.InformedEntity alias Screens.Stops.Stop alias Screens.V2.WidgetInstance.SubwayStatus alias ScreensConfig.Screen @@ -112,7 +113,7 @@ defmodule Screens.V2.WidgetInstance.SubwayStatus do def audio_serialize(t), do: serialize(t) - def audio_sort_key(_instance), do: [1] + def audio_sort_key(_instance), do: [2] def audio_valid_candidate?(%{screen: %Screen{app_params: %PreFare{}}}), do: true def audio_valid_candidate?(_instance), do: false @@ -288,29 +289,17 @@ defmodule Screens.V2.WidgetInstance.SubwayStatus do %{type: :text, color: :green, text: "GL", branches: branches} end - defp ie_is_whole_route?(%{route: route_id, direction_id: nil, stop: nil}) - when not is_nil(route_id), - do: true - - defp ie_is_whole_route?(_), do: false - - defp ie_is_whole_direction?(%{route: route_id, direction_id: direction_id, stop: nil}) - when not is_nil(route_id) and not is_nil(direction_id), - do: true - - defp ie_is_whole_direction?(_), do: false - defp alert_is_whole_route?(informed_entities) do - Enum.any?(informed_entities, &ie_is_whole_route?/1) + Enum.any?(informed_entities, &InformedEntity.whole_route?/1) end defp alert_is_whole_direction?(informed_entities) do - Enum.any?(informed_entities, &ie_is_whole_direction?/1) + Enum.any?(informed_entities, &InformedEntity.whole_direction?/1) end defp get_direction(informed_entities, route_id) do [%{direction_id: direction_id} | _] = - Enum.filter(informed_entities, &ie_is_whole_direction?/1) + Enum.filter(informed_entities, &InformedEntity.whole_direction?/1) direction = @route_directions diff --git a/lib/screens_web/views/v2/audio/content_summary_view.ex b/lib/screens_web/views/v2/audio/content_summary_view.ex index 6007dda1a..8ed975871 100644 --- a/lib/screens_web/views/v2/audio/content_summary_view.ex +++ b/lib/screens_web/views/v2/audio/content_summary_view.ex @@ -2,7 +2,7 @@ defmodule ScreensWeb.V2.Audio.ContentSummaryView do use ScreensWeb, :view def render("_widget.ssml", %{lines_at_station: lines}) do - ~E|

You will hear the subway service overview, the current alerts for the <%= render_lines_at_station(lines) %>, and system elevator closures

| + ~E|

You will hear the current alerts for the <%= render_lines_at_station(lines) %>, the subway service overview, and system elevator closures

| end defp render_lines_at_station([line]) do diff --git a/lib/screens_web/views/v2/audio/reconstructed_alert_single_screen_view.ex b/lib/screens_web/views/v2/audio/reconstructed_alert_single_screen_view.ex new file mode 100644 index 000000000..6be41060a --- /dev/null +++ b/lib/screens_web/views/v2/audio/reconstructed_alert_single_screen_view.ex @@ -0,0 +1,223 @@ +defmodule ScreensWeb.V2.Audio.ReconstructedAlertSingleScreenView do + use ScreensWeb, :view + + alias Screens.Alerts.Alert + alias Screens.Util + + def render("_widget.ssml", alert) do + ~E|

<%= render_banner(alert) %><%= render_alert(alert) %>

| + end + + # The field `unaffected_routes` is reserved for single line closures at transfer station + def render_banner(%{ + unaffected_routes: _unaffected_routes + }), + do: nil + + # The field `region` is reserved for single-pane alerts. + # Routes will only be empty if the banner should be empty (e.g. multiline delay) + def render_banner(%{region: _region, routes: []}), do: ~E|Attention, riders. | + + def render_banner(%{ + region: _region, + routes: routes + }) do + if routes |> hd() |> Map.has_key?(:headsign) do + destinations = Enum.map(routes, fn route -> route.headsign end) + + if length(destinations) < 3 do + ~E|Attention, riders to <%= Util.format_name_list_to_string_audio(destinations) %>. | + else + ~E|Attention, riders. | + end + else + ~E|Attention, <%= hd(routes).route_id %> line riders. | + end + end + + def render_banner(_), do: nil + + # Delay + def render_alert( + %{ + effect: :delay, + issue: issue + } = alert + ) do + ~E|<%= issue %><%= render_cause(alert.cause) %>.| + end + + # Downstream closure + def render_alert(%{ + region: :outside, + effect: :station_closure, + routes: route_svg_names, + cause: cause, + stations: stations + }) do + ~E|<%= get_line_name(route_svg_names) %> trains are skipping <%= Util.format_name_list_to_string_audio(stations) %><%= render_cause(cause) %>. Please seek an alternate route.| + end + + # Downstream shuttle + def render_alert(%{ + region: :outside, + effect: :shuttle, + routes: route_svg_names, + endpoints: {left_endpoint, right_endpoint}, + cause: cause + }) do + ~E|Shuttle buses replace <%= get_line_name(route_svg_names) %> trains between <%= left_endpoint %> and <%= right_endpoint %><%= render_cause(cause) %>. All shuttle buses are accessible.| + end + + # Downstream suspension + def render_alert(%{ + region: :outside, + effect: :suspension, + routes: route_svg_names, + endpoints: {left_endpoint, right_endpoint}, + cause: cause + }) do + ~E|There are no <%= get_line_name(route_svg_names) %> trains between <%= left_endpoint %> and <%= right_endpoint %><%= render_cause(cause) %>. Please seek an alternate route.| + end + + # Boundary shuttle + def render_alert(%{ + region: :boundary, + effect: :shuttle, + issue: issue, + routes: route_svg_names, + endpoints: {left_endpoint, right_endpoint}, + cause: cause + }) do + ~E|There are <%= issue %>. Please use the shuttle bus. Shuttle buses are replacing <%= get_line_name(route_svg_names) %> trains between <%= left_endpoint %> and <%= right_endpoint %><%= render_cause(cause) %>. All shuttle buses are accessible.| + end + + # Boundary suspension + def render_alert(%{ + region: :boundary, + effect: :suspension, + issue: issue, + routes: route_svg_names, + endpoints: {left_endpoint, right_endpoint}, + cause: cause + }) do + ~E|There are <%= issue %>. Please seek an alternate route. Please note that there are no <%= get_line_name(route_svg_names) %> trains between <%= left_endpoint %> and <%= right_endpoint %><%= render_cause(cause) %>.| + end + + # Closure here - three cases + + # Case 1: Multiple stations, single line impacted + def render_alert(%{ + effect: :station_closure, + routes: route_svg_names, + cause: cause, + other_closures: other_closures + }) + when other_closures != [] do + ~E|This station is closed<%= render_cause(cause) %>. Please seek an alternate route. <%= get_line_name(route_svg_names) %> trains are skipping this station and <%= Util.format_name_list_to_string_audio(other_closures) %>.| + end + + # Case 2: Single line impacted at transfer station + def render_alert(%{ + effect: :station_closure, + routes: route_svg_names, + cause: cause, + unaffected_routes: unaffected_routes + }) do + ~E|<%= get_line_name(route_svg_names) %> trains are skipping this station<%= render_cause(cause) %>. Please seek an alternate route. <%= get_line_name(unaffected_routes) %> trains are stopping here as usual.| + end + + # Case 3: Single station, single line impacted + # The pattern to match is very simple here, because other station closures will + # be caught by previous clauses, so this pattern is specifically for a closure here + def render_alert(%{ + effect: :station_closure, + routes: route_svg_names, + cause: cause + }) do + ~E|This station is closed<%= render_cause(cause) %>. Please seek an alternate route. <%= get_line_name(route_svg_names) %> trains are skipping this station.| + end + + # Shuttle here + def render_alert( + %{ + effect: :shuttle, + routes: route_svg_names, + endpoints: {left_endpoint, right_endpoint}, + cause: cause + } = alert + ) do + if Map.has_key?(alert, :is_transfer_station) do + ~E|There are no <%= get_line_name(route_svg_names) %> trains. Please use the shuttle bus. Shuttle buses are replacing <%= get_line_name(route_svg_names) %> trains between <%= left_endpoint %> and <%= right_endpoint %><%= render_cause(cause) %>. All shuttle buses are accessible.| + else + ~E|This station is closed. Please use the shuttle bus. Shuttle buses are replacing <%= get_line_name(route_svg_names) %> trains between <%= left_endpoint %> and <%= right_endpoint %><%= render_cause(cause) %>. All shuttle buses are accessible.| + end + end + + # Suspension here + def render_alert( + %{ + effect: :suspension, + routes: route_svg_names, + endpoints: {left_endpoint, right_endpoint}, + cause: cause + } = alert + ) do + if Map.has_key?(alert, :is_transfer_station) do + ~E|There are no <%= get_line_name(route_svg_names) %> trains<%= render_cause(cause) %>. Please seek an alternate route. Please note that there are no <%= get_line_name(route_svg_names) %> trains between <%= left_endpoint %> and <%= right_endpoint %>.| + else + ~E|This station is closed<%= render_cause(cause) %>. Please seek an alternate route. Please note that there are no <%= get_line_name(route_svg_names) %> trains between <%= left_endpoint %> and <%= right_endpoint %>.| + end + end + + # Fallback + def render_alert(%{ + issue: issue, + remedy: remedy, + remedy_bold: remedy_bold + }) do + if is_nil(remedy_bold) do + ~E|<%= issue %>. <%= remedy %>| + else + ~E|<%= remedy_bold %>.| + end + end + + defp get_line_name([%{color: _color, text: _text, type: _type} | _tail] = routes) do + routes + |> Enum.map(fn route -> route.text end) + |> Util.format_name_list_to_string_audio() + end + + defp get_line_name(routes) do + route_ids = + routes + |> Enum.map(fn route -> route.route_id end) + + branch_letters = for "Green-" <> branch_letter <- route_ids, do: branch_letter + + lines_without_branches = + route_ids + |> Enum.reject(&String.contains?(&1, "Green-")) + |> Enum.map(fn line -> line <> " Line" end) + + branch_or_branches = if length(branch_letters) == 1, do: "branch", else: "branches" + + formatted_lines_with_branches = + if branch_letters !== [], + do: [ + "Green Line #{branch_or_branches}, " <> + Util.format_name_list_to_string_audio(branch_letters) + ], + else: [] + + list_of_lines = + (formatted_lines_with_branches ++ lines_without_branches) + |> Util.format_name_list_to_string_audio() + + ~E|<%= list_of_lines %>| + end + + defp render_cause(nil), do: nil + defp render_cause(cause), do: " #{Alert.get_cause_string(cause)}" +end diff --git a/mix.exs b/mix.exs index a0c2770be..ff9fabfcc 100644 --- a/mix.exs +++ b/mix.exs @@ -79,6 +79,7 @@ defmodule Screens.MixProject do {:retry, "~> 0.16.0"}, {:stream_data, "~> 0.5", only: :test}, {:memcachex, "~> 0.5.5"}, + {:aja, "~> 0.6.2"}, {:telemetry_poller, "~> 0.4"}, {:telemetry_metrics, "~> 0.4"}, {:screens_config, git: "https://github.com/mbta/screens-config-lib.git"} diff --git a/mix.lock b/mix.lock index 14490ef9d..14d8f74b9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "aja": {:hex, :aja, "0.6.2", "3eae51bc26dd479ad53b07ec9254bc018ab9b95704db13817df6a1ecf1c817de", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1f0a1aab112dacec73914b4e30a7215cda6cab7b0fb0adf5472dc3bf227d8b34"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, diff --git a/test/screens/route_patterns/route_pattern_test.exs b/test/screens/route_patterns/route_pattern_test.exs index 889ba933b..7d6c74e5d 100644 --- a/test/screens/route_patterns/route_pattern_test.exs +++ b/test/screens/route_patterns/route_pattern_test.exs @@ -3,7 +3,7 @@ defmodule Screens.RoutePatterns.RoutePatternTest do import Screens.RoutePatterns.RoutePattern - describe "fetch_stop_sequences_through_stop/2" do + describe "fetch_tagged_stop_sequences_through_stop/2" do test "returns {:ok, sequences} if fetch function returns {:ok, data}" do stop_id = "1265" @@ -18,13 +18,22 @@ defmodule Screens.RoutePatterns.RoutePatternTest do %{ "type" => "trip", "relationships" => %{ - "stops" => %{"data" => [%{"id" => "1"}, %{"id" => "2"}, %{"id" => "3"}]} + "stops" => %{"data" => [%{"id" => "1"}, %{"id" => "2"}, %{"id" => "3"}]}, + "route" => %{"data" => %{"id" => "route1"}} } }, %{ "type" => "trip", "relationships" => %{ - "stops" => %{"data" => [%{"id" => "5"}, %{"id" => "6"}, %{"id" => "7"}]} + "stops" => %{"data" => [%{"id" => "3"}, %{"id" => "2"}, %{"id" => "1"}]}, + "route" => %{"data" => %{"id" => "route1"}} + } + }, + %{ + "type" => "trip", + "relationships" => %{ + "stops" => %{"data" => [%{"id" => "5"}, %{"id" => "6"}, %{"id" => "7"}]}, + "route" => %{"data" => %{"id" => "route2"}} } } ] @@ -32,10 +41,10 @@ defmodule Screens.RoutePatterns.RoutePatternTest do get_json_fn = fn _, ^params -> {:ok, data} end - expected_stop_sequences = [~w[1 2 3], ~w[5 6 7]] + expected_stop_sequences = %{"route1" => [~w[1 2 3], ~w[3 2 1]], "route2" => [~w[5 6 7]]} assert {:ok, expected_stop_sequences} == - fetch_stop_sequences_through_stop(stop_id, [], get_json_fn) + fetch_tagged_stop_sequences_through_stop(stop_id, [], get_json_fn) end test "returns :error if fetch function returns :error" do @@ -43,7 +52,7 @@ defmodule Screens.RoutePatterns.RoutePatternTest do get_json_fn = fn _, _ -> :error end - assert :error == fetch_stop_sequences_through_stop(stop_id, [], get_json_fn) + assert :error == fetch_tagged_stop_sequences_through_stop(stop_id, [], get_json_fn) end test "returns filtered list if route_filters is provided" do @@ -70,10 +79,10 @@ defmodule Screens.RoutePatterns.RoutePatternTest do get_json_fn = fn _, ^params -> {:ok, data} end - expected_stop_sequences = [~w[5 6 7]] + expected_stop_sequences = %{"Orange" => [~w[5 6 7]]} assert {:ok, expected_stop_sequences} == - fetch_stop_sequences_through_stop(stop_id, route_filters, get_json_fn) + fetch_tagged_stop_sequences_through_stop(stop_id, route_filters, get_json_fn) end end end diff --git a/test/screens/util_test.exs b/test/screens/util_test.exs index 782b8211a..00ec2be4e 100644 --- a/test/screens/util_test.exs +++ b/test/screens/util_test.exs @@ -8,12 +8,12 @@ defmodule Screens.UtilTest do assert "Alewife" === format_name_list_to_string(["Alewife"]) end - test "returns 'X and Y' if list has length 2" do - assert "Alewife and Davis" === format_name_list_to_string(["Alewife", "Davis"]) + test "returns 'X & Y' if list has length 2" do + assert "Alewife & Davis" === format_name_list_to_string(["Alewife", "Davis"]) end - test "returns 'X, Y, and Z' if list has length >= 3" do - assert "Alewife, Davis, Porter, and Harvard" === + test "returns 'X, Y, & Z' if list has length >= 3" do + assert "Alewife, Davis, Porter, & Harvard" === format_name_list_to_string(["Alewife", "Davis", "Porter", "Harvard"]) end end diff --git a/test/screens/v2/candidate_generator/widgets/alerts_test.exs b/test/screens/v2/candidate_generator/widgets/alerts_test.exs index ff35b845b..1a964cf6b 100644 --- a/test/screens/v2/candidate_generator/widgets/alerts_test.exs +++ b/test/screens/v2/candidate_generator/widgets/alerts_test.exs @@ -7,6 +7,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.AlertsTest do alias ScreensConfig.Screen alias ScreensConfig.V2.{Alerts, BusShelter, Solari} alias Screens.LocationContext + alias Screens.RoutePatterns.RoutePattern alias Screens.Stops.Stop alias Screens.V2.WidgetInstance.Alert, as: AlertWidget @@ -35,12 +36,14 @@ defmodule Screens.V2.CandidateGenerator.Widgets.AlertsTest do %{route_id: "44", active?: true} ] - stop_sequences = [ - ~w[11531 1265 1266], - ~w[1262 11531 1265 1266 10413], - ~w[1265 1266 10413 11413 17411], - ~w[1260 1262 11531 1265] - ] + tagged_stop_sequences = %{ + "A" => [~w[11531 1265 1266]], + "B" => [~w[1262 11531 1265 1266 10413]], + "C" => [~w[1265 1266 10413 11413 17411]], + "D" => [~w[1260 1262 11531 1265]] + } + + stop_sequences = RoutePattern.untag_stop_sequences(tagged_stop_sequences) alerts = [ %Alert{ @@ -66,7 +69,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.AlertsTest do location_context = %LocationContext{ home_stop: stop_id, - stop_sequences: stop_sequences, + tagged_stop_sequences: tagged_stop_sequences, upstream_stops: Stop.upstream_stop_id_set(stop_id, stop_sequences), downstream_stops: Stop.downstream_stop_id_set(stop_id, stop_sequences), routes: routes_at_stop, diff --git a/test/screens/v2/candidate_generator/widgets/reconstructed_alert_test.exs b/test/screens/v2/candidate_generator/widgets/reconstructed_alert_test.exs index 6e7489f64..32b099522 100644 --- a/test/screens/v2/candidate_generator/widgets/reconstructed_alert_test.exs +++ b/test/screens/v2/candidate_generator/widgets/reconstructed_alert_test.exs @@ -8,6 +8,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alias ScreensConfig.V2.Header.CurrentStopId alias ScreensConfig.V2.{PreFare, Solari} alias Screens.LocationContext + alias Screens.RoutePatterns.RoutePattern alias Screens.Stops.Stop alias Screens.V2.WidgetInstance.ReconstructedAlert, as: ReconstructedAlertWidget @@ -22,7 +23,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do describe "reconstructed_alert_instances/5" do setup do - stop_id = "place-hsmnl" + stop_id = "place-ogmnl" app = PreFare @@ -36,86 +37,35 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do routes_at_stop = [ %{ - route_id: "Red", + route_id: "Orange", active?: true, direction_destinations: nil, long_name: nil, short_name: nil, type: :subway - }, - %{ - route_id: "Green-B", - active?: false, - direction_destinations: nil, - long_name: nil, - short_name: nil, - type: :light_rail - }, - %{ - route_id: "Green-C", - active?: true, - direction_destinations: nil, - long_name: nil, - short_name: nil, - type: :light_rail - }, - %{ - route_id: "Green-D", - active?: true, - direction_destinations: nil, - long_name: nil, - short_name: nil, - type: :light_rail - }, - %{ - route_id: "Green-E", - active?: true, - direction_destinations: nil, - long_name: nil, - short_name: nil, - type: :light_rail } ] happening_now_active_period = [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] - upcoming_active_period = [{~U[2021-01-02T00:00:00Z], ~U[2021-01-03T00:00:00Z]}] alerts = [ %Alert{ id: "1", effect: :station_closure, - informed_entities: [ie(stop: "place-hsmnl")], + informed_entities: [ie(stop: "place-ogmnl")], active_period: happening_now_active_period }, %Alert{ id: "2", effect: :station_closure, - informed_entities: [ie(stop: "place-bckhl")], + informed_entities: [ie(stop: "place-mlmnl")], active_period: happening_now_active_period }, %Alert{ id: "3", effect: :delay, - informed_entities: [ie(stop: "place-hsmnl")], + informed_entities: [ie(stop: "place-ogmnl")], active_period: happening_now_active_period - }, - %Alert{ - id: "4", - effect: :station_closure, - informed_entities: [], - active_period: happening_now_active_period - }, - %Alert{ - id: "5", - effect: :stop_closure, - informed_entities: [ie(stop: "place-rvrwy")], - active_period: happening_now_active_period - }, - %Alert{ - id: "6", - effect: :station_closure, - informed_entities: [ie(stop: "place-hsmnl")], - active_period: upcoming_active_period } ] @@ -123,30 +73,39 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do %Alert{ id: "1", effect: :delay, - informed_entities: [ie(stop: "place-hsmnl", direction_id: 0)], + informed_entities: [ie(stop: "place-ogmnl", direction_id: 0)], active_period: happening_now_active_period }, %Alert{ id: "2", effect: :delay, - informed_entities: [ie(stop: "place-hsmnl")], + informed_entities: [ie(stop: "place-ogmnl")], active_period: happening_now_active_period }, %Alert{ id: "3", effect: :delay, - informed_entities: [ie(stop: "place-hsmnl", direction_id: 1)], + informed_entities: [ie(stop: "place-ogmnl", direction_id: 1)], active_period: happening_now_active_period } ] - stop_sequences = [ - ["place-hsmnl", "place-bckhl", "place-rvrwy", "place-mispk"] - ] + tagged_stop_sequences = %{ + "A" => [["place-ogmnl", "place-mlmnl", "place-welln", "place-astao"]] + } + + stop_sequences = RoutePattern.untag_stop_sequences(tagged_stop_sequences) + + fetch_stop_name_fn = fn + "place-ogmnl" -> "Oak Grove" + "place-mlmnl" -> "Malden Center" + "place-welln" -> "Wellington" + "place-astao" -> "Assembly" + end location_context = %LocationContext{ home_stop: stop_id, - stop_sequences: stop_sequences, + tagged_stop_sequences: tagged_stop_sequences, upstream_stops: Stop.upstream_stop_id_set(stop_id, stop_sequences), downstream_stops: Stop.downstream_stop_id_set(stop_id, stop_sequences), routes: routes_at_stop, @@ -158,10 +117,10 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do bad_config: bad_config, location_context: location_context, now: ~U[2021-01-01T00:00:00Z], - informed_stations_string: "Alewife", + happening_now_active_period: happening_now_active_period, fetch_alerts_fn: fn _ -> {:ok, alerts} end, fetch_directional_alerts_fn: fn _ -> {:ok, directional_alerts} end, - fetch_stop_name_fn: fn _ -> "Alewife" end, + fetch_stop_name_fn: fetch_stop_name_fn, fetch_location_context_fn: fn _, _, _ -> {:ok, location_context} end, x_fetch_alerts_fn: fn _ -> :error end, x_fetch_stop_name_fn: fn _ -> :error end, @@ -169,22 +128,43 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do } end - test "returns a list of alert widgets if all queries succeed", context do + test "returns fullscreen instances for immediate disruptions", context do %{ config: config, location_context: location_context, now: now, - informed_stations_string: informed_stations_string, - fetch_alerts_fn: fetch_alerts_fn, + happening_now_active_period: happening_now_active_period, fetch_stop_name_fn: fetch_stop_name_fn, fetch_location_context_fn: fetch_location_context_fn } = context + alerts = [ + %Alert{ + id: "1", + effect: :station_closure, + informed_entities: [ie(stop: "place-ogmnl")], + active_period: happening_now_active_period + }, + %Alert{ + id: "2", + effect: :station_closure, + informed_entities: [ie(stop: "place-mlmnl")], + active_period: happening_now_active_period + }, + %Alert{ + id: "3", + effect: :delay, + informed_entities: [ie(stop: "place-ogmnl")], + active_period: happening_now_active_period + } + ] + + fetch_alerts_fn = fn _ -> {:ok, alerts} end + expected_common_data = %{ screen: config, location_context: location_context, now: now, - informed_stations_string: informed_stations_string, is_terminal_station: true } @@ -194,9 +174,11 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alert: %Alert{ id: "1", effect: :station_closure, - informed_entities: [ie(stop: "place-hsmnl")], + informed_entities: [ie(stop: "place-ogmnl")], active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] - } + }, + is_full_screen: true, + informed_stations: ["Oak Grove"] }, expected_common_data ), @@ -205,9 +187,10 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alert: %Alert{ id: "2", effect: :station_closure, - informed_entities: [ie(stop: "place-bckhl")], + informed_entities: [ie(stop: "place-mlmnl")], active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] - } + }, + informed_stations: ["Malden Center"] }, expected_common_data ), @@ -216,9 +199,160 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alert: %Alert{ id: "3", effect: :delay, - informed_entities: [ie(stop: "place-hsmnl")], + informed_entities: [ie(stop: "place-ogmnl")], active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] - } + }, + is_full_screen: false, + informed_stations: [] + }, + expected_common_data + ) + ] + + assert expected_widgets == + reconstructed_alert_instances( + config, + now, + fetch_alerts_fn, + fetch_stop_name_fn, + fetch_location_context_fn + ) + end + + test "returns fullscreen instances for closest downstream disruptions if no immediate disruptions", + context do + %{ + config: config, + location_context: location_context, + now: now, + happening_now_active_period: happening_now_active_period, + fetch_stop_name_fn: fetch_stop_name_fn, + fetch_location_context_fn: fetch_location_context_fn + } = context + + alerts = [ + %Alert{ + id: "1", + effect: :station_closure, + informed_entities: [ie(stop: "place-mlmnl")], + active_period: happening_now_active_period + }, + %Alert{ + id: "2", + effect: :station_closure, + informed_entities: [ie(stop: "place-astao")], + active_period: happening_now_active_period + }, + %Alert{ + id: "3", + effect: :shuttle, + informed_entities: [ie(stop: "place-mlmnl"), ie(stop: "place-welln")], + active_period: happening_now_active_period + } + ] + + fetch_alerts_fn = fn _ -> {:ok, alerts} end + + expected_common_data = %{ + screen: config, + location_context: location_context, + now: now, + is_terminal_station: true + } + + expected_widgets = [ + struct( + %ReconstructedAlertWidget{ + alert: %Alert{ + id: "1", + effect: :station_closure, + informed_entities: [ie(stop: "place-mlmnl")], + active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] + }, + is_full_screen: true, + informed_stations: ["Malden Center"] + }, + expected_common_data + ), + struct( + %ReconstructedAlertWidget{ + alert: %Alert{ + id: "3", + effect: :shuttle, + informed_entities: [ie(stop: "place-mlmnl"), ie(stop: "place-welln")], + active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] + }, + is_full_screen: true, + informed_stations: [] + }, + expected_common_data + ), + struct( + %ReconstructedAlertWidget{ + alert: %Alert{ + id: "2", + effect: :station_closure, + informed_entities: [ie(stop: "place-astao")], + active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] + }, + informed_stations: ["Assembly"] + }, + expected_common_data + ) + ] + + assert expected_widgets == + reconstructed_alert_instances( + config, + now, + fetch_alerts_fn, + fetch_stop_name_fn, + fetch_location_context_fn + ) + end + + test "returns fullscreen instances for moderate disruptions if no immediate/downstream disruptions", + context do + %{ + config: config, + location_context: location_context, + now: now, + happening_now_active_period: happening_now_active_period, + fetch_stop_name_fn: fetch_stop_name_fn, + fetch_location_context_fn: fetch_location_context_fn + } = context + + alerts = [ + %Alert{ + id: "1", + effect: :delay, + severity: 6, + informed_entities: [ie(stop: "place-mlmnl")], + active_period: happening_now_active_period + } + ] + + fetch_alerts_fn = fn _ -> {:ok, alerts} end + + expected_common_data = %{ + screen: config, + location_context: location_context, + now: now, + is_terminal_station: true + } + + expected_widgets = [ + struct( + %ReconstructedAlertWidget{ + alert: %Alert{ + id: "1", + effect: :delay, + severity: 6, + informed_entities: [ie(stop: "place-mlmnl")], + active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] + }, + is_full_screen: true, + informed_stations: [] }, expected_common_data ) @@ -298,7 +432,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do screen: config, location_context: location_context, now: now, - informed_stations_string: "", + informed_stations: [], is_terminal_station: true } @@ -308,9 +442,10 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alert: %Alert{ id: "1", effect: :station_closure, - informed_entities: [ie(stop: "place-hsmnl")], + informed_entities: [ie(stop: "place-ogmnl")], active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] - } + }, + is_full_screen: true }, expected_common_data ), @@ -319,7 +454,7 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alert: %Alert{ id: "2", effect: :station_closure, - informed_entities: [ie(stop: "place-bckhl")], + informed_entities: [ie(stop: "place-mlmnl")], active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] } }, @@ -330,9 +465,10 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alert: %Alert{ id: "3", effect: :delay, - informed_entities: [ie(stop: "place-hsmnl")], + informed_entities: [ie(stop: "place-ogmnl")], active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] - } + }, + is_full_screen: false }, expected_common_data ) @@ -353,7 +489,6 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do config: config, location_context: location_context, now: now, - informed_stations_string: informed_stations_string, fetch_directional_alerts_fn: fetch_directional_alerts_fn, fetch_stop_name_fn: fetch_stop_name_fn, fetch_location_context_fn: fetch_location_context_fn @@ -363,8 +498,8 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do screen: config, location_context: location_context, now: now, - informed_stations_string: informed_stations_string, - is_terminal_station: true + is_terminal_station: true, + is_full_screen: true } expected_widgets = [ @@ -373,9 +508,10 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alert: %Alert{ id: "1", effect: :delay, - informed_entities: [ie(stop: "place-hsmnl", direction_id: 0)], + informed_entities: [ie(stop: "place-ogmnl", direction_id: 0)], active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] - } + }, + informed_stations: [] }, expected_common_data ), @@ -384,9 +520,10 @@ defmodule Screens.V2.CandidateGenerator.Widgets.ReconstructedAlertTest do alert: %Alert{ id: "2", effect: :delay, - informed_entities: [ie(stop: "place-hsmnl")], + informed_entities: [ie(stop: "place-ogmnl")], active_period: [{~U[2020-12-31T00:00:00Z], ~U[2021-01-02T00:00:00Z]}] - } + }, + informed_stations: [] }, expected_common_data ) diff --git a/test/screens/v2/candidate_generator/widgets/train_crowding_test.exs b/test/screens/v2/candidate_generator/widgets/train_crowding_test.exs index eaffe7888..e393950ff 100644 --- a/test/screens/v2/candidate_generator/widgets/train_crowding_test.exs +++ b/test/screens/v2/candidate_generator/widgets/train_crowding_test.exs @@ -41,30 +41,32 @@ defmodule Screens.V2.CandidateGenerator.Widgets.TrainCrowdingTest do location_context = %Screens.LocationContext{ home_stop: "place-masta", - stop_sequences: [ - [ - "place-ogmnl", - "place-mlmnl", - "place-welln", - "place-astao", - "place-sull", - "place-ccmnl", - "place-north", - "place-haecl", - "place-state", - "place-dwnxg", - "place-chncl", - "place-tumnl", - "place-bbsta", - "place-masta", - "place-rugg", - "place-rcmnl", - "place-jaksn", - "place-sbmnl", - "place-grnst", - "place-forhl" + tagged_stop_sequences: %{ + "Orange" => [ + [ + "place-ogmnl", + "place-mlmnl", + "place-welln", + "place-astao", + "place-sull", + "place-ccmnl", + "place-north", + "place-haecl", + "place-state", + "place-dwnxg", + "place-chncl", + "place-tumnl", + "place-bbsta", + "place-masta", + "place-rugg", + "place-rcmnl", + "place-jaksn", + "place-sbmnl", + "place-grnst", + "place-forhl" + ] ] - ], + }, upstream_stops: MapSet.new([ "place-astao", diff --git a/test/screens/v2/disruption_diagram_test.exs b/test/screens/v2/disruption_diagram_test.exs new file mode 100644 index 000000000..d9a8d8d71 --- /dev/null +++ b/test/screens/v2/disruption_diagram_test.exs @@ -0,0 +1,2236 @@ +defmodule Screens.V2.DisruptionDiagramTest do + use ExUnit.Case, async: true + + alias Screens.V2.DisruptionDiagram, as: DD + alias Screens.LocationContext + alias Screens.Alerts.Alert + alias Screens.TestSupport.DisruptionDiagramLocalizedAlert, as: DDAlert + alias Screens.TestSupport.SubwayTaggedStopSequences, as: TaggedSeq + + import Screens.TestSupport.ParentStationIdSigil + + describe "serialize/1" do + ############# + # BLUE LINE # + ############# + + test "serializes a Blue Line shuttle" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :blue, ~P"mvbcl", {~P"wondl", ~P"mvbcl"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {4, 11}, + line: :blue, + current_station_slot_index: 4, + slots: [ + %{type: :terminal, label_id: ~P"bomnl"}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true}, + %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true}, + %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true}, + %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true}, + %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true}, + %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true}, + %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true}, + %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"wondl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Blue Line suspension" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :blue, ~P"gover", {~P"state", ~P"bomnl"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {0, 2}, + line: :blue, + current_station_slot_index: 1, + slots: [ + %{type: :terminal, label_id: ~P"bomnl"}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true}, + %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true}, + %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true}, + %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true}, + %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true}, + %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true}, + %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true}, + %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"wondl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Blue Line station closure" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :blue, ~P"wondl", ~P[mvbcl aport]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [4, 5], + line: :blue, + current_station_slot_index: 11, + slots: [ + %{type: :terminal, label_id: ~P"bomnl"}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true}, + %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true}, + %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true}, + %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true}, + %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true}, + %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true}, + %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true}, + %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"wondl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Blue Line station closure at Government Center, which is also the home stop" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :blue, ~P"gover", [~P"gover"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [1], + line: :blue, + current_station_slot_index: 1, + slots: [ + %{type: :terminal, label_id: ~P"bomnl"}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true}, + %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true}, + %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true}, + %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true}, + %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true}, + %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true}, + %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true}, + %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"wondl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + ############### + # ORANGE LINE # + ############### + + test "serializes an Orange Line trunk station closure at Downtown Crossing, which is also the home stop" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :orange, ~P"dwnxg", [~P"dwnxg"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [3], + line: :orange, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + # + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + # + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + # + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes an Orange Line station closure far from home stop" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :orange, ~P"sbmnl", [~P"welln"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [2], + line: :orange, + current_station_slot_index: 7, + slots: [ + %{type: :terminal, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true}, + # + # + %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true}, + # Com College, North Sta, Haymarket, State, Downt'n Xng, Chinatown, Tufts Med, Back Bay, Mass Ave, Ruggles, Roxbury Xng + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + # + # + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true}, + # + %{type: :terminal, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes an Orange Line suspension spanning most of the line" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :orange, ~P"welln", {~P"astao", ~P"grnst"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {3, 10}, + line: :orange, + current_station_slot_index: 2, + slots: [ + %{type: :terminal, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + # + # + # + %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true}, + %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true}, + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + # Haymarket, State, Downt'n Xng, Chinatown, Tufts Med, Back Bay, Mass Ave, Ruggles, Roxbury Xng + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true}, + # + %{type: :terminal, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a long Orange Line shuttle some distance from the home stop" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :orange, ~P"mlmnl", {~P"ccmnl", ~P"grnst"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {4, 10}, + line: :orange, + current_station_slot_index: 1, + slots: [ + # + %{type: :terminal, label_id: ~P"ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + # + # + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + # Assembly, Sullivan Sq + %{label: "…", show_symbol: false}, + # + # + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # State, Downt'n Xng, Chinatown, Tufts Med, Back Bay, Mass Ave, Ruggles, Roxbury Xng + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + # + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true}, + # + %{type: :terminal, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a short Orange Line shuttle close to the home stop, at one end of the line" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :orange, ~P"rugg", {~P"jaksn", ~P"forhl"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {4, 7}, + line: :orange, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true}, + %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true}, + # + # + %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true}, + # + # + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"forhl"} + # + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a short Orange Line suspension some distance from the home stop, at one end of the line" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :orange, ~P"tumnl", {~P"mlmnl", ~P"astao"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {1, 3}, + line: :orange, + current_station_slot_index: 7, + slots: [ + %{type: :terminal, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true}, + # + # + %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true}, + # Com College, North Sta, Haymarket, State, Downt'n Xng + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + # + # + %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true}, + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a long Orange Line suspension near the home stop, at one end of the line" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :orange, ~P"sbmnl", {~P"ccmnl", ~P"rugg"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {1, 6}, + line: :orange, + current_station_slot_index: 9, + slots: [ + %{type: :arrow, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + # Haymarket, State, Downt'n Xng, Chinatown, Tufts Med + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true}, + %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true}, + # + # + %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true}, + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + # + # + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true}, + # + %{type: :terminal, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a long Orange Line shuttle some distance from the home stop, at one end of the line" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :orange, ~P"rcmnl", {~P"mlmnl", ~P"chncl"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 6}, + line: :orange, + current_station_slot_index: 9, + slots: [ + %{type: :terminal, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + # Assembly, Sullivan Sq, Com College, North Sta, Haymarket + %{label: "…", show_symbol: false}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + # + # + # Tufts Med, Back Bay, Mass Ave + %{label: "…", show_symbol: false}, + %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true}, + # + %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true}, + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a short Orange Line station closure near the home stop, around the middle of the line" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :orange, ~P"tumnl", [~P"dwnxg"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [2], + line: :orange, + current_station_slot_index: 4, + slots: [ + %{type: :arrow, label_id: ~P"ogmnl"}, + # + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + # + # + # + %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true}, + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a medium Orange Line suspension some distance from the home stop, around the middle of the line" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :orange, ~P"rcmnl", {~P"sull", ~P"dwnxg"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {1, 6}, + line: :orange, + current_station_slot_index: 11, + slots: [ + %{type: :arrow, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true}, + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + # + # + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + # Tufts Med, Back Bay + %{label: "…", show_symbol: false}, + %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true}, + %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true}, + # + # + %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true}, + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a long Orange Line shuttle containing the home stop, around the middle of the line" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :orange, ~P"bbsta", {~P"sull", ~P"rugg"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 10}, + line: :orange, + current_station_slot_index: 8, + slots: [ + %{type: :arrow, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true}, + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # State, Downt'n Xng + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true}, + # + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true}, + # + %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a long Orange Line station closure some distance from the home stop, near the middle of the line" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :orange, ~P"jaksn", [~P"astao", ~P"tumnl"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [2, 5], + line: :orange, + current_station_slot_index: 9, + slots: [ + %{type: :arrow, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true}, + # Com College, North Sta, Haymarket, State, Downt'n Xng, Chinatown + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true}, + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + # + # + # Mass Ave, Ruggles + %{label: "…", show_symbol: false}, + %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true}, + # + # + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - trunk - L terminal - closure omission + # Red - trunk - L terminal - gap and closure omission + # Red - trunk - L arrow - gap omission + # Red - trunk - L arrow - closure omission + # Red - trunk - L arrow - gap and closure omission + # ... + # Red - trunk -arrows - no omissions (good opportunity to test padding small diagram away from JFK) + + ################## + # RED LINE TRUNK # + ################## + + test "serializes a Red Line trunk station closure at Downtown Crossing, which is also the home stop" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :red, ~P"dwnxg", [~P"dwnxg"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [3], + line: :red, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: ~P"alfcl"}, + # + %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true}, + # + # + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + # + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + # + %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - trunk - L terminal - no omission + test "serializes a Red Line trunk shuttle near the home stop" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :red, ~P"portr", {~P"knncl", ~P"jfk"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {5, 12}, + line: :red, + current_station_slot_index: 2, + slots: [ + %{type: :terminal, label_id: ~P"alfcl"}, + # + %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true}, + %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true}, + # + # + %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true}, + %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true}, + # + # + %{label: %{full: "Kendall/MIT", abbrev: "Kendall/MIT"}, show_symbol: true}, + %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true}, + %{label: %{full: "Broadway", abbrev: "Broadway"}, show_symbol: true}, + %{label: %{full: "Andrew", abbrev: "Andrew"}, show_symbol: true}, + %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - trunk - L terminal - gap omission + test "serializes a Red Line trunk station closure some distance from the home stop" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :red, ~P"jfk", ~P[davis portr]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [1, 2], + line: :red, + current_station_slot_index: 10, + slots: [ + # + %{type: :terminal, label_id: ~P"alfcl"}, + %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true}, + %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true}, + %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true}, + # + # + %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true}, + %{label: %{full: "Kendall/MIT", abbrev: "Kendall/MIT"}, show_symbol: true}, + # Charles/MGH, Park St, Downt'n Xng + %{ + label: %{abbrev: "…via Downt'n Xng", full: "…via Downtown Crossing"}, + show_symbol: false + }, + %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true}, + %{label: %{full: "Broadway", abbrev: "Broadway"}, show_symbol: true}, + %{label: %{full: "Andrew", abbrev: "Andrew"}, show_symbol: true}, + # + # + %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - trunk - L terminal - no omission, with padding plan A + test "serializes a short Red Line station closure next to the home stop, near Alewife" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :red, ~P"portr", [~P"davis"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [1], + line: :red, + current_station_slot_index: 2, + slots: [ + # + %{type: :terminal, label_id: ~P"alfcl"}, + %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true}, + # + %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true}, + # + %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true}, + # + # + %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - trunk - L terminal - no omission, with padding plan B + test "serializes a short Red Line station closure near the home stop, which is Alewife" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :red, ~P"alfcl", [~P"davis"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [1], + line: :red, + current_station_slot_index: 0, + slots: [ + # + # + %{type: :terminal, label_id: ~P"alfcl"}, + # + %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true}, + %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true}, + # + # + %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true}, + %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - trunk - L terminal - no omission, with padding plan A and B + test "serializes a short Red Line suspension including the home stop, which is Porter" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :red, ~P"portr", {~P"portr", ~P"harsq"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {2, 3}, + line: :red, + current_station_slot_index: 2, + slots: [ + %{type: :terminal, label_id: ~P"alfcl"}, + # + %{label: %{full: "Davis", abbrev: "Davis"}, show_symbol: true}, + # + # + # + %{label: %{full: "Porter", abbrev: "Porter"}, show_symbol: true}, + %{label: %{full: "Harvard", abbrev: "Harvard"}, show_symbol: true}, + # + # + # + %{label: %{full: "Central", abbrev: "Central"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - trunk - L arrow - no omission + test "serializes a Red Line trunk shuttle around the middle of the trunk" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :red, ~P"dwnxg", {~P"chmnl", ~P"sstat"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 4}, + line: :red, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: ~P"alfcl"}, + %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true}, + %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Red Line trunk shuttle with home stop at JFK" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :red, ~P"jfk", {~P"chmnl", ~P"sstat"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 4}, + line: :red, + current_station_slot_index: 7, + slots: [ + %{type: :arrow, label_id: ~P"alfcl"}, + # + %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true}, + # + # + %{label: %{full: "Broadway", abbrev: "Broadway"}, show_symbol: true}, + %{label: %{full: "Andrew", abbrev: "Andrew"}, show_symbol: true}, + # + # + %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"asmnl" <> "+" <> ~P"brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Red Line shuttle that crosses from trunk to Ashmont branch" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :red, ~P"smmnl", {~P"jfk", ~P"fldcr"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 3}, + line: :red, + current_station_slot_index: 4, + slots: [ + %{type: :arrow, label_id: ~P"alfcl"}, + # + %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true}, + %{label: %{full: "Savin Hill", abbrev: "Savin Hill"}, show_symbol: true}, + %{label: %{full: "Fields Corner", abbrev: "Fields Cnr"}, show_symbol: true}, + # + # + # + %{label: %{full: "Shawmut", abbrev: "Shawmut"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"asmnl"} + # + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Red Line suspension that crosses from trunk to Braintree branch" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :red, ~P"dwnxg", {~P"jfk", ~P"brntn"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {6, 11}, + line: :red, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"alfcl"}, + # + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + # + # + %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true}, + %{label: %{full: "Broadway", abbrev: "Broadway"}, show_symbol: true}, + %{label: %{full: "Andrew", abbrev: "Andrew"}, show_symbol: true}, + # + # + %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true}, + %{label: %{full: "North Quincy", abbrev: "N Quincy"}, show_symbol: true}, + %{label: %{full: "Wollaston", abbrev: "Wollaston"}, show_symbol: true}, + %{label: %{full: "Quincy Center", abbrev: "Quincy Ctr"}, show_symbol: true}, + %{label: %{full: "Quincy Adams", abbrev: "Quincy Adms"}, show_symbol: true}, + %{type: :terminal, label_id: "place-brntn"} + # + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + #################### + # RED LINE ASHMONT # + #################### + + # Red - Ashmont - trunk alert with home stop on branch + test "serializes a Red Line trunk suspension with home stop on the Ashmont branch" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :red, ~P"shmnl", {~P"chmnl", ~P"sstat"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 4}, + line: :red, + current_station_slot_index: 8, + slots: [ + %{type: :arrow, label_id: "place-alfcl"}, + # + %{label: %{abbrev: "Charles/MGH", full: "Charles/MGH"}, show_symbol: true}, + %{label: %{abbrev: "Park St", full: "Park Street"}, show_symbol: true}, + %{label: %{abbrev: "Downt'n Xng", full: "Downtown Crossing"}, show_symbol: true}, + %{label: %{abbrev: "South Sta", full: "South Station"}, show_symbol: true}, + # + # + %{label: %{abbrev: "Broadway", full: "Broadway"}, show_symbol: true}, + %{label: %{abbrev: "Andrew", full: "Andrew"}, show_symbol: true}, + %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true}, + # + # + %{label: %{abbrev: "Savin Hill", full: "Savin Hill"}, show_symbol: true}, + %{label: %{abbrev: "Fields Cnr", full: "Fields Corner"}, show_symbol: true}, + # + %{type: :arrow, label_id: "place-asmnl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - Ashmont - branch alert with home stop on trunk + test "serializes a Red Line Ashmont branch station closure with home stop on the trunk" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :red, ~P"portr", [~P"shmnl"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [7], + line: :red, + current_station_slot_index: 2, + slots: [ + %{type: :terminal, label_id: "place-alfcl"}, + # + %{label: %{abbrev: "Davis", full: "Davis"}, show_symbol: true}, + %{label: %{abbrev: "Porter", full: "Porter"}, show_symbol: true}, + # + # + %{label: %{abbrev: "Harvard", full: "Harvard"}, show_symbol: true}, + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + %{label: %{abbrev: "Andrew", full: "Andrew"}, show_symbol: true}, + # + # + %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true}, + %{label: %{abbrev: "Savin Hill", full: "Savin Hill"}, show_symbol: true}, + %{label: %{abbrev: "Fields Cnr", full: "Fields Corner"}, show_symbol: true}, + # + %{type: :arrow, label_id: "place-asmnl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Red Line Ashmont branch shuttle with home stop on the Ashmont branch" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :red, ~P"fldcr", {~P"shmnl", ~P"asmnl"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {2, 5}, + line: :red, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: ~P"alfcl"}, + # + %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true}, + # + # + %{label: %{full: "Savin Hill", abbrev: "Savin Hill"}, show_symbol: true}, + %{label: %{full: "Fields Corner", abbrev: "Fields Cnr"}, show_symbol: true}, + %{label: %{full: "Shawmut", abbrev: "Shawmut"}, show_symbol: true}, + # + %{type: :terminal, label_id: ~P"asmnl"} + # + # + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Red Line trunk station closure with home stop on the Ashmont branch" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :red, ~P"asmnl", [~P"chmnl"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [2], + line: :red, + current_station_slot_index: 9, + slots: [ + %{type: :arrow, label_id: ~P"alfcl"}, + # + %{label: %{full: "Kendall/MIT", abbrev: "Kendall/MIT"}, show_symbol: true}, + %{label: %{full: "Charles/MGH", abbrev: "Charles/MGH"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + # + # + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "South Station", abbrev: "South Sta"}, show_symbol: true}, + %{label: "…", show_symbol: false}, + %{label: %{full: "Fields Corner", abbrev: "Fields Cnr"}, show_symbol: true}, + %{label: %{full: "Shawmut", abbrev: "Shawmut"}, show_symbol: true}, + # + # + %{type: :terminal, label_id: ~P"asmnl"} + # + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + ###################### + # RED LINE BRAINTREE # + ###################### + + # Red - Braintree - trunk alert with home stop on branch + test "serializes a Red Line trunk suspension with home stop on the Braintree branch" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :red, ~P"wlsta", {~P"chmnl", ~P"sstat"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 4}, + line: :red, + current_station_slot_index: 9, + slots: [ + %{type: :arrow, label_id: "place-alfcl"}, + # + %{label: %{abbrev: "Charles/MGH", full: "Charles/MGH"}, show_symbol: true}, + %{label: %{abbrev: "Park St", full: "Park Street"}, show_symbol: true}, + %{label: %{abbrev: "Downt'n Xng", full: "Downtown Crossing"}, show_symbol: true}, + %{label: %{abbrev: "South Sta", full: "South Station"}, show_symbol: true}, + # + # + %{label: %{abbrev: "Broadway", full: "Broadway"}, show_symbol: true}, + %{label: %{abbrev: "Andrew", full: "Andrew"}, show_symbol: true}, + %{label: %{abbrev: "JFK/UMass", full: "JFK/UMass"}, show_symbol: true}, + %{label: %{abbrev: "N Quincy", full: "North Quincy"}, show_symbol: true}, + # + # + %{label: %{abbrev: "Wollaston", full: "Wollaston"}, show_symbol: true}, + %{label: %{abbrev: "Quincy Ctr", full: "Quincy Center"}, show_symbol: true}, + # + %{type: :arrow, label_id: "place-brntn"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - Braintree - branch alert with home stop on trunk + test "serializes a Red Line Braintree branch station closure with home stop on the trunk" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :red, ~P"portr", [~P"qamnl"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [8], + line: :red, + current_station_slot_index: 2, + slots: [ + %{label_id: "place-alfcl", type: :terminal}, + %{label: %{abbrev: "Davis", full: "Davis"}, show_symbol: true}, + %{label: %{abbrev: "Porter", full: "Porter"}, show_symbol: true}, + %{label: %{abbrev: "Harvard", full: "Harvard"}, show_symbol: true}, + %{label: %{abbrev: "Central", full: "Central"}, show_symbol: true}, + %{ + label: %{abbrev: "…via Downt'n Xng", full: "…via Downtown Crossing"}, + show_symbol: false + }, + %{label: %{abbrev: "Wollaston", full: "Wollaston"}, show_symbol: true}, + %{label: %{abbrev: "Quincy Ctr", full: "Quincy Center"}, show_symbol: true}, + %{label: %{abbrev: "Quincy Adms", full: "Quincy Adams"}, show_symbol: true}, + %{label_id: "place-brntn", type: :terminal} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + # Red - Braintree - branch alert with home stop on branch + test "serializes a Red Line Braintree branch shuttle with home stop on the Braintree branch" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :red, ~P"nqncy", {~P"nqncy", ~P"brntn"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {2, 6}, + line: :red, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"alfcl"}, + # + %{label: %{full: "JFK/UMass", abbrev: "JFK/UMass"}, show_symbol: true}, + # + %{label: %{full: "North Quincy", abbrev: "N Quincy"}, show_symbol: true}, + # + %{label: %{full: "Wollaston", abbrev: "Wollaston"}, show_symbol: true}, + %{label: %{full: "Quincy Center", abbrev: "Quincy Ctr"}, show_symbol: true}, + %{label: %{full: "Quincy Adams", abbrev: "Quincy Adms"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"brntn"} + # + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + #################### + # GREEN LINE TRUNK # + #################### + + test "serializes a Green Line trunk station closure at Government Center, which is also the home stop" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :green, ~P"gover", [~P"gover"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [3], + line: :green, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: ~P"coecl" <> "+west"}, + # + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + # + # + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + # + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + # + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Green Line trunk station closure at North Station, which is also the home stop" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :green, ~P"north", [~P"north"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [3], + line: :green, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: ~P"coecl" <> "+west"}, + # + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + # + # + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + # + %{label: %{full: "Science Park/West End", abbrev: "Science Pk"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Green Line trunk suspension with home stop on the trunk" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :green, ~P"north", {~P"haecl", ~P"pktrm"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {1, 3}, + line: :green, + current_station_slot_index: 4, + slots: [ + %{type: :arrow, label_id: ~P"coecl" <> "+west"}, + # + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + # + # + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Science Park/West End", abbrev: "Science Pk"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes the same alert viewed from home stop at Union Square" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :green, ~P"unsqu", {~P"haecl", ~P"pktrm"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {4, 6}, + line: :green, + current_station_slot_index: 0, + slots: [ + # + %{type: :terminal, label_id: ~P"unsqu"}, + # + # + %{label: %{full: "Lechmere", abbrev: "Lechmere"}, show_symbol: true}, + %{label: %{full: "Science Park/West End", abbrev: "Science Pk"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + # + # + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"river"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes the same alert viewed from home stop on Medford branch" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :green, ~P"gilmn", {~P"haecl", ~P"pktrm"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {6, 8}, + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"mdftf"}, + # + %{label: %{full: "Magoun Square", abbrev: "Magoun Sq"}, show_symbol: true}, + %{label: %{full: "Gilman Square", abbrev: "Gilman Sq"}, show_symbol: true}, + # + # + %{label: %{full: "East Somerville", abbrev: "E Somerville"}, show_symbol: true}, + # Lechmere, Science Pk + %{label: "…", show_symbol: false}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + # + # + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"hsmnl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes the same alert viewed from home stop on Riverside branch" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :green, ~P"fenwy", {~P"haecl", ~P"pktrm"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {6, 8}, + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"river"}, + # + %{label: %{full: "Longwood", abbrev: "Longwood"}, show_symbol: true}, + %{label: %{full: "Fenway", abbrev: "Fenway"}, show_symbol: true}, + # + # + %{label: %{full: "Kenmore", abbrev: "Kenmore"}, show_symbol: true}, + %{label: %{full: "…via Copley", abbrev: "…via Copley"}, show_symbol: false}, + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + # + # + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"unsqu"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes the same alert viewed from home stop on Heath Street branch" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :green, ~P"symcl", {~P"haecl", ~P"pktrm"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {6, 8}, + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"hsmnl"}, + # + %{label: %{full: "Northeastern University", abbrev: "Northeast'n"}, show_symbol: true}, + %{label: %{full: "Symphony", abbrev: "Symphony"}, show_symbol: true}, + # + # + %{label: %{full: "Prudential", abbrev: "Prudential"}, show_symbol: true}, + %{label: "…", show_symbol: false}, + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + # + # + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"mdftf"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a trunk alert that does not extend past Government Center when home stop is on Boston College branch" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :green, ~P"amory", {~P"boyls", ~P"coecl"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {6, 8}, + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"lake"}, + # + %{label: %{full: "Babcock Street", abbrev: "Babcock St"}, show_symbol: true}, + %{label: %{full: "Amory Street", abbrev: "Amory St"}, show_symbol: true}, + # + # + %{label: %{full: "Boston University Central", abbrev: "BU Central"}, show_symbol: true}, + %{label: %{full: "…via Kenmore", abbrev: "…via Kenmore"}, show_symbol: false}, + %{label: %{full: "Hynes Convention Center", abbrev: "Hynes"}, show_symbol: true}, + # + # + %{label: %{full: "Copley", abbrev: "Copley"}, show_symbol: true}, + %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true}, + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"gover"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a trunk alert that does not extend past Government Center when home stop is on Cleveland Circle branch" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :green, ~P"cool", {~P"boyls", ~P"coecl"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {6, 8}, + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"clmnl"}, + # + %{label: %{full: "Summit Avenue", abbrev: "Summit Ave"}, show_symbol: true}, + %{label: %{full: "Coolidge Corner", abbrev: "Coolidge Cn"}, show_symbol: true}, + # + # + %{label: %{full: "Saint Paul Street", abbrev: "St. Paul St"}, show_symbol: true}, + %{label: %{full: "…via Kenmore", abbrev: "…via Kenmore"}, show_symbol: false}, + %{label: %{full: "Hynes Convention Center", abbrev: "Hynes"}, show_symbol: true}, + # + # + %{label: %{full: "Copley", abbrev: "Copley"}, show_symbol: true}, + %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true}, + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"gover"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "uses 'Kenmore & West' label for a Green Line trunk alert extending past Copley but not Kenmore" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :green, ~P"coecl", {~P"haecl", ~P"pktrm"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {5, 7}, + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"kencl+west"}, + # + %{label: %{full: "Hynes Convention Center", abbrev: "Hynes"}, show_symbol: true}, + %{label: %{full: "Copley", abbrev: "Copley"}, show_symbol: true}, + # + # + %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true}, + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + # + # + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + ####################### + # GREEN LINE BRANCHES # + ####################### + + test "serializes a Medford branch alert" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :green, ~P"gilmn", [~P"mgngl"]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [2], + line: :green, + current_station_slot_index: 3, + slots: [ + %{type: :terminal, label_id: ~P"mdftf"}, + %{label: %{full: "Ball Square", abbrev: "Ball Sq"}, show_symbol: true}, + %{label: %{full: "Magoun Square", abbrev: "Magoun Sq"}, show_symbol: true}, + %{label: %{full: "Gilman Square", abbrev: "Gilman Sq"}, show_symbol: true}, + %{label: %{full: "East Somerville", abbrev: "E Somerville"}, show_symbol: true}, + %{type: :arrow, label_id: ~P"hsmnl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Union Square branch alert" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :green, ~P"unsqu", {~P"unsqu", ~P"lech"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {0, 1}, + line: :green, + current_station_slot_index: 0, + slots: [ + %{type: :terminal, label_id: ~P"unsqu"}, + %{label: %{full: "Lechmere", abbrev: "Lechmere"}, show_symbol: true}, + %{label: %{full: "Science Park/West End", abbrev: "Science Pk"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + %{type: :arrow, label_id: ~P"river"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Boston College branch alert" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :green, ~P"sthld", {~P"babck", ~P"alsgr"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {5, 9}, + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"lake"}, + # + %{label: %{full: "Chiswick Road", abbrev: "Chiswick Rd"}, show_symbol: true}, + %{label: %{full: "Sutherland Road", abbrev: "Sutherland"}, show_symbol: true}, + # + # + %{label: %{full: "Washington Street", abbrev: "Washington"}, show_symbol: true}, + %{label: %{full: "Warren Street", abbrev: "Warren St"}, show_symbol: true}, + # + # + %{label: %{full: "Allston Street", abbrev: "Allston St"}, show_symbol: true}, + %{label: %{full: "Griggs Street", abbrev: "Griggs St"}, show_symbol: true}, + %{label: %{full: "Harvard Avenue", abbrev: "Harvard Ave"}, show_symbol: true}, + %{label: %{full: "Packards Corner", abbrev: "Packards Cn"}, show_symbol: true}, + %{label: %{full: "Babcock Street", abbrev: "Babcock St"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"gover"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Cleveland Circle branch alert" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :green, ~P"cool", {~P"sumav", ~P"bndhl"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 2}, + line: :green, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: ~P"clmnl"}, + # + %{label: %{full: "Brandon Hall", abbrev: "Brandon Hll"}, show_symbol: true}, + %{label: %{full: "Summit Avenue", abbrev: "Summit Ave"}, show_symbol: true}, + # + # + %{label: %{full: "Coolidge Corner", abbrev: "Coolidge Cn"}, show_symbol: true}, + %{label: %{full: "Saint Paul Street", abbrev: "St. Paul St"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"gover"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Riverside branch alert" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :green, ~P"rsmnl", {~P"chhil", ~P"newto"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 2}, + line: :green, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: ~P"river"}, + # + %{label: %{full: "Newton Centre", abbrev: "Newton Ctr"}, show_symbol: true}, + %{label: %{full: "Chestnut Hill", abbrev: "Chestnut Hl"}, show_symbol: true}, + # + # + %{label: %{full: "Reservoir", abbrev: "Reservoir"}, show_symbol: true}, + %{label: %{full: "Beaconsfield", abbrev: "B'consfield"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"unsqu"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Heath Street branch alert" do + localized_alert = + DDAlert.make_localized_alert(:suspension, :green, ~P"symcl", {~P"brmnl", ~P"hsmnl"}) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {0, 5}, + line: :green, + current_station_slot_index: 9, + slots: [ + # + %{type: :terminal, label_id: ~P"hsmnl"}, + %{label: %{full: "Back of the Hill", abbrev: "Back o'Hill"}, show_symbol: true}, + %{label: %{full: "Riverway", abbrev: "Riverway"}, show_symbol: true}, + %{label: %{full: "Mission Park", abbrev: "Mission Pk"}, show_symbol: true}, + %{label: %{full: "Fenwood Road", abbrev: "Fenwood Rd"}, show_symbol: true}, + %{label: %{full: "Brigham Circle", abbrev: "Brigham Cir"}, show_symbol: true}, + # + # + %{label: %{full: "Longwood Medical Area", abbrev: "Lngwd Med"}, show_symbol: true}, + %{label: %{full: "Museum of Fine Arts", abbrev: "MFA"}, show_symbol: true}, + %{label: %{full: "Northeastern University", abbrev: "Northeast'n"}, show_symbol: true}, + # + # + %{label: %{full: "Symphony", abbrev: "Symphony"}, show_symbol: true}, + %{label: %{full: "Prudential", abbrev: "Prudential"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"mdftf"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "serializes a Cleveland Circle branch alert with home stop at Government Center" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :green, ~P"gover", {~P"smary", ~P"cool"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 5}, + line: :green, + current_station_slot_index: 11, + slots: [ + %{type: :arrow, label_id: ~P"clmnl"}, + %{label: %{full: "Coolidge Corner", abbrev: "Coolidge Cn"}, show_symbol: true}, + %{label: %{full: "Saint Paul Street", abbrev: "St. Paul St"}, show_symbol: true}, + %{label: %{full: "Kent Street", abbrev: "Kent St"}, show_symbol: true}, + %{label: %{full: "Hawes Street", abbrev: "Hawes St"}, show_symbol: true}, + %{label: %{full: "Saint Mary's Street", abbrev: "St. Mary's"}, show_symbol: true}, + %{label: %{full: "Kenmore", abbrev: "Kenmore"}, show_symbol: true}, + %{label: %{full: "Hynes Convention Center", abbrev: "Hynes"}, show_symbol: true}, + %{ + label: %{full: "…via Copley", abbrev: "…via Copley"}, + show_symbol: false + }, + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"gover"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + ############## + # VALIDATION # + ############## + + test "rejects irrelevant alert effects" do + delay_scenario = %{ + alert: %Alert{effect: :delay, informed_entities: [%{route: "Orange", stop: ~P"rugg"}]}, + location_context: %LocationContext{ + home_stop: ~P"bbsta", + tagged_stop_sequences: TaggedSeq.orange() + } + } + + assert {:error, "invalid effect: delay"} = DD.serialize(delay_scenario) + end + + test "rejects whole-route alerts" do + whole_route_scenario = %{ + alert: %Alert{ + effect: :suspension, + informed_entities: [%{route: "Orange", stop: nil, direction_id: nil}] + }, + location_context: %LocationContext{ + home_stop: ~P"bbsta", + tagged_stop_sequences: TaggedSeq.orange() + } + } + + assert {:error, "alert informs an entire route"} = DD.serialize(whole_route_scenario) + end + + test "rejects shuttle and suspension alerts that inform only one stop" do + one_stop_shuttle = + DDAlert.make_localized_alert(:shuttle, :blue, ~P"gover", {~P"mvbcl", ~P"mvbcl"}) + + assert {:error, "shuttle alert does not inform at least 2 stops"} = + DD.serialize(one_stop_shuttle) + + one_stop_suspension = + DDAlert.make_localized_alert(:suspension, :green, ~P"north", {~P"kencl", ~P"kencl"}) + + assert {:error, "suspension alert does not inform at least 2 stops"} = + DD.serialize(one_stop_suspension) + end + + test "rejects alerts that inform multiple lines and can't be filtered to one line" do + multi_line_scenario = %{ + alert: %Alert{ + effect: :station_closure, + informed_entities: [ + %{route: "Blue", stop: ~P"gover"} + | Enum.map(~w[B C D E], &%{route: "Green-#{&1}", stop: ~P"gover"}) + ] + }, + location_context: %LocationContext{ + home_stop: ~P"gover", + tagged_stop_sequences: Map.merge(TaggedSeq.blue(), TaggedSeq.green()), + routes: [ + %{route_id: "Blue", active?: true} + | Enum.map(~w[B C D E], &%{route_id: "Green-#{&1}", active?: true}) + ] + } + } + + assert {:error, + "alert does not inform exactly one subway line, and home stop location does not help us choose one of the informed lines"} = + DD.serialize(multi_line_scenario) + end + + test "rejects alerts whose informed stops do not all lay along one stop sequence" do + branched_scenario = %{ + alert: %Alert{ + effect: :station_closure, + informed_entities: [ + %{route: "Green-D", stop: ~P"unsqu"}, + %{route: "Green-E", stop: ~P"mdftf"} + ] + }, + location_context: %LocationContext{ + home_stop: ~P"gover", + tagged_stop_sequences: TaggedSeq.green() + } + } + + assert {:error, "no stop sequence contains both the home stop and all informed stops"} = + DD.serialize(branched_scenario) + end + + test "rejects alerts whose informed stops include a branch that's not directly reachable from the home stop" do + unreachable_branch_scenario = %{ + alert: %Alert{ + effect: :shuttle, + informed_entities: [ + %{route: "Green-E", stop: ~P"coecl"}, + %{route: "Green-E", stop: ~P"prmnl"}, + %{route: "Green-E", stop: ~P"symcl"} + ] + }, + location_context: %LocationContext{ + home_stop: ~P"unsqu", + tagged_stop_sequences: TaggedSeq.green([:d]) + } + } + + assert {:error, "no stop sequence contains both the home stop and all informed stops"} = + DD.serialize(unreachable_branch_scenario) + end + + ############## + # EDGE CASES # + ############## + + test "serializes an alert informing 2 lines when home stop is served by only one of the informed lines (BL)" do + multi_line_scenario = %{ + alert: %Alert{ + effect: :station_closure, + informed_entities: [ + %{route: "Blue", stop: ~P"gover"} + | Enum.map(~w[B C D E], &%{route: "Green-#{&1}", stop: ~P"gover"}) + ] + }, + location_context: %LocationContext{ + home_stop: ~P"wondl", + tagged_stop_sequences: Map.merge(TaggedSeq.blue(), TaggedSeq.green()), + routes: [%{route_id: "Blue", active?: true}] + } + } + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [1], + line: :blue, + current_station_slot_index: 11, + slots: [ + %{type: :terminal, label_id: ~P"bomnl"}, + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Aquarium", abbrev: "Aquarium"}, show_symbol: true}, + %{label: %{full: "Maverick", abbrev: "Maverick"}, show_symbol: true}, + %{label: %{full: "Airport", abbrev: "Airport"}, show_symbol: true}, + %{label: %{full: "Wood Island", abbrev: "Wood Island"}, show_symbol: true}, + %{label: %{full: "Orient Heights", abbrev: "Orient Hts"}, show_symbol: true}, + %{label: %{full: "Suffolk Downs", abbrev: "Suffolk Dns"}, show_symbol: true}, + %{label: %{full: "Beachmont", abbrev: "Beachmont"}, show_symbol: true}, + %{label: %{full: "Revere Beach", abbrev: "Revere Bch"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"wondl"} + ] + } + + assert {:ok, actual} = DD.serialize(multi_line_scenario) + + assert expected == actual + end + + test "serializes an alert informing 2 lines when home stop is served by only one of the informed lines (GL)" do + multi_line_scenario = %{ + alert: %Alert{ + effect: :station_closure, + informed_entities: [ + %{route: "Blue", stop: ~P"gover"} + | Enum.map(~w[B C D E], &%{route: "Green-#{&1}", stop: ~P"gover"}) + ] + }, + location_context: %LocationContext{ + home_stop: ~P"pktrm", + tagged_stop_sequences: Map.merge(TaggedSeq.red(), TaggedSeq.green()), + routes: [ + %{route_id: "Red", active?: true} + | Enum.map(~w[B C D E], &%{route_id: "Green-#{&1}", active?: true}) + ] + } + } + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [3], + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"coecl" <> "+west"}, + # + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + # + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + # + %{label: %{full: "Government Center", abbrev: "Gov't Ctr"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{type: :arrow, label_id: ~P"mdftf" <> "+" <> ~P"unsqu"} + ] + } + + assert {:ok, actual} = DD.serialize(multi_line_scenario) + + assert expected == actual + end + + test "serializes an alert informing 2 lines when home stop is served by only one of the informed lines (GL branch)" do + multi_line_scenario = %{ + alert: %Alert{ + effect: :station_closure, + informed_entities: [ + %{route: "Red", stop: ~P"pktrm"} + | Enum.map(~w[B C D E], &%{route: "Green-#{&1}", stop: ~P"pktrm"}) + ] + }, + location_context: %LocationContext{ + home_stop: ~P"bucen", + tagged_stop_sequences: TaggedSeq.green([:b]), + routes: [%{route_id: "Green-B", active?: true}] + } + } + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [8], + line: :green, + current_station_slot_index: 2, + slots: [ + %{type: :arrow, label_id: ~P"lake"}, + # + %{label: %{full: "Amory Street", abbrev: "Amory St"}, show_symbol: true}, + %{label: %{full: "Boston University Central", abbrev: "BU Central"}, show_symbol: true}, + # + # + %{label: %{full: "Boston University East", abbrev: "BU East"}, show_symbol: true}, + %{label: %{full: "Blandford Street", abbrev: "Blandford"}, show_symbol: true}, + # Kenmore, Hynes, Copley + %{ + label: %{full: "…via Kenmore & Copley", abbrev: "…via Kenmore & Copley"}, + show_symbol: false + }, + %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true}, + # + # + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"gover"} + # + ] + } + + assert {:ok, actual} = DD.serialize(multi_line_scenario) + + assert expected == actual + end + + test "does not omit from an alert that spans 9 stops and contains the home stop" do + # In this case, the closure has more than 8 slots available to it and doesn't get shrunk. + localized_alert = + DDAlert.make_localized_alert( + :suspension, + :orange, + ~P"haecl", + {~P"ccmnl", ~P"masta"} + ) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {1, 9}, + line: :orange, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: "place-ogmnl"}, + # + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + # + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true}, + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true}, + # + %{type: :arrow, label_id: "place-forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "does not omit from an alert that spans 10 stops and contains the home stop" do + # In this case, the closure has more than 8 slots available to it and doesn't get shrunk. + localized_alert = + DDAlert.make_localized_alert( + :suspension, + :orange, + ~P"haecl", + {~P"ccmnl", ~P"rugg"} + ) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {1, 10}, + line: :orange, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: "place-ogmnl"}, + # + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + # + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true}, + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true}, + %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true}, + # + %{type: :arrow, label_id: "place-forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "omits from an alert that spans more than 10 stops and contains the home stop" do + # The largest a closure can possibly be is 10 slots. + localized_alert = + DDAlert.make_localized_alert( + :suspension, + :orange, + ~P"haecl", + {~P"ccmnl", ~P"rcmnl"} + ) + + expected = %{ + effect: :suspension, + effect_region_slot_index_range: {1, 10}, + line: :orange, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: "place-ogmnl"}, + # + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + # + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + # + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + # Chinatown, Tufts Med + %{label: "…", show_symbol: false}, + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true}, + %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true}, + %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true}, + # + %{type: :arrow, label_id: "place-forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "for long shuttles with home stop near the middle, omits stops off-center to avoid omitting the home stop" do + localized_alert = + DDAlert.make_localized_alert(:shuttle, :orange, ~P"bbsta", {~P"mlmnl", ~P"grnst"}) + + expected = %{ + effect: :shuttle, + effect_region_slot_index_range: {1, 10}, + line: :orange, + current_station_slot_index: 4, + slots: [ + %{type: :terminal, label_id: ~P"ogmnl"}, + # + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + # Assembly, Sullivan Sq, Com College, North Sta, Haymarket, State, Downt'n Xng, Chinatown, Tufts Med + # (Shifted left 3 to avoid omitting home stop at Back Bay) + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + # + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + %{label: %{full: "Massachusetts Avenue", abbrev: "Mass Ave"}, show_symbol: true}, + # + %{label: %{full: "Ruggles", abbrev: "Ruggles"}, show_symbol: true}, + %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true}, + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true}, + # + %{type: :terminal, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "for long station closures with closures near the middle, omits stops off-center to avoid omitting the home stop" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :orange, ~P"welln", ~P[mlmnl haecl grnst]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [1, 7, 10], + line: :orange, + current_station_slot_index: 2, + slots: [ + %{type: :terminal, label_id: ~P"ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true}, + %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true}, + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + # State, Downt'n Xng, Chinatown, Tufts Med, Back Bay, Mass Ave, Ruggles, Roxbury Xng, Jackson Sq + %{ + label: %{full: "…via Downtown Crossing", abbrev: "…via Downt'n Xng"}, + show_symbol: false + }, + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true}, + %{type: :terminal, label_id: ~P"forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "splits omission around an important stop when necessary" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :orange, ~P"welln", ~P[mlmnl dwnxg grnst]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [1, 5, 10], + line: :orange, + current_station_slot_index: 2, + slots: [ + %{type: :terminal, label_id: "place-ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true}, + %{label: "…", show_symbol: false}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: "…", show_symbol: false}, + %{label: %{full: "Roxbury Crossing", abbrev: "Roxbury Xng"}, show_symbol: true}, + %{label: %{full: "Jackson Square", abbrev: "Jackson Sq"}, show_symbol: true}, + %{label: %{full: "Stony Brook", abbrev: "Stony Brook"}, show_symbol: true}, + %{label: %{full: "Green Street", abbrev: "Green St"}, show_symbol: true}, + %{type: :terminal, label_id: "place-forhl"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + test "absolute worst case scenario--split omission + gap omission" do + localized_alert = + DDAlert.make_localized_alert(:station_closure, :green, ~P"unsqu", ~P[boyls brkhl waban]) + + expected = %{ + effect: :station_closure, + closed_station_slot_indices: [2, 4, 7], + line: :green, + current_station_slot_index: 11, + slots: [ + %{type: :terminal, label_id: "place-river"}, + %{label: %{full: "Woodland", abbrev: "Woodland"}, show_symbol: true}, + %{label: %{full: "Waban", abbrev: "Waban"}, show_symbol: true}, + %{label: "…", show_symbol: false}, + %{label: %{full: "Brookline Hills", abbrev: "B'kline Hls"}, show_symbol: true}, + %{ + label: %{full: "…via Kenmore & Copley", abbrev: "…via Kenmore & Copley"}, + show_symbol: false + }, + %{label: %{full: "Arlington", abbrev: "Arlington"}, show_symbol: true}, + %{label: %{full: "Boylston", abbrev: "Boylston"}, show_symbol: true}, + %{label: %{full: "Park Street", abbrev: "Park St"}, show_symbol: true}, + %{label: "…", show_symbol: false}, + %{label: %{full: "Lechmere", abbrev: "Lechmere"}, show_symbol: true}, + %{type: :terminal, label_id: "place-unsqu"} + ] + } + + assert {:ok, actual} = DD.serialize(localized_alert) + + assert expected == actual + end + + ########### + # FAILURE # + ########### + + test "fails to serialize a station closure that's impossible to fit without omitting an important stop" do + localized_alert = + DDAlert.make_localized_alert( + :station_closure, + :orange, + ~P"welln", + ~P[mlmnl astao ccmnl haecl dwnxg tumnl masta rcmnl sbmnl] + ) + + expected = + {:error, "can't omit 9 from closure region without omitting at least one important stop"} + + assert expected == DD.serialize(localized_alert) + end + end +end diff --git a/test/screens/v2/localized_alert_test.exs b/test/screens/v2/localized_alert_test.exs index ddf203974..7633162a4 100644 --- a/test/screens/v2/localized_alert_test.exs +++ b/test/screens/v2/localized_alert_test.exs @@ -5,6 +5,7 @@ defmodule Screens.V2.LocalizedAlertTest do alias ScreensConfig.Screen alias ScreensConfig.V2.BusShelter alias Screens.LocationContext + alias Screens.RoutePatterns.RoutePattern alias Screens.RouteType alias Screens.Stops.Stop alias Screens.V2.LocalizedAlert, as: LocalizedAlert @@ -19,7 +20,7 @@ defmodule Screens.V2.LocalizedAlertTest do screen: %Screen{app_params: nil, vendor: nil, device_id: nil, name: nil, app_id: nil}, location_context: %LocationContext{ home_stop: nil, - stop_sequences: nil, + tagged_stop_sequences: nil, upstream_stops: nil, downstream_stops: nil, routes: nil, @@ -44,12 +45,14 @@ defmodule Screens.V2.LocalizedAlertTest do %{widget | alert: %{widget.alert | informed_entities: ies}} end - defp put_stop_sequences(widget, sequences) do + defp put_tagged_stop_sequences(widget, tagged_sequences) do + sequences = RoutePattern.untag_stop_sequences(tagged_sequences) + %{ widget | location_context: %{ widget.location_context - | stop_sequences: sequences, + | tagged_stop_sequences: tagged_sequences, upstream_stops: Stop.upstream_stop_id_set(widget.location_context.home_stop, sequences), downstream_stops: @@ -80,16 +83,20 @@ defmodule Screens.V2.LocalizedAlertTest do %{widget: put_home_stop(widget, BusShelter, home_stop)} end - defp setup_stop_sequences(%{widget: widget}) do - stop_sequences = [ - ~w[0 1 2 3 4 5 6 7 8 9], - ~w[10 20 30 4 5 7], - ~w[ 5 6 90], - ~w[200 40 5], - ~w[111 222 333] - ] + defp setup_tagged_stop_sequences(%{widget: widget}) do + tagged_stop_sequences = %{ + "A" => [ + ~w[0 1 2 3 4 5 6 7 8 9], + ~w[10 20 30 4 5 7] + ], + "B" => [ + ~w[ 5 6 90], + ~w[200 40 5], + ~w[111 222 333] + ] + } - %{widget: put_stop_sequences(widget, stop_sequences)} + %{widget: put_tagged_stop_sequences(widget, tagged_stop_sequences)} end defp setup_routes(%{widget: widget}) do @@ -105,7 +112,7 @@ defmodule Screens.V2.LocalizedAlertTest do defp setup_location_context(%{widget: widget}) do %{widget: widget} |> setup_home_stop() - |> setup_stop_sequences() + |> setup_tagged_stop_sequences() |> setup_routes() end diff --git a/test/screens/v2/widget_instance/alert_test.exs b/test/screens/v2/widget_instance/alert_test.exs index 1cca8a277..e04aaaf08 100644 --- a/test/screens/v2/widget_instance/alert_test.exs +++ b/test/screens/v2/widget_instance/alert_test.exs @@ -5,6 +5,7 @@ defmodule Screens.V2.WidgetInstance.AlertTest do alias ScreensConfig.Screen alias ScreensConfig.V2.{BusEink, BusShelter, GlEink} alias Screens.LocationContext + alias Screens.RoutePatterns.RoutePattern alias Screens.Stops.Stop alias Screens.V2.AlertsWidget alias Screens.V2.WidgetInstance.Alert, as: AlertWidget @@ -18,7 +19,7 @@ defmodule Screens.V2.WidgetInstance.AlertTest do screen: %Screen{app_params: nil, vendor: nil, device_id: nil, name: nil, app_id: nil}, location_context: %LocationContext{ home_stop: nil, - stop_sequences: nil, + tagged_stop_sequences: nil, upstream_stops: nil, downstream_stops: nil, routes: nil, @@ -47,12 +48,14 @@ defmodule Screens.V2.WidgetInstance.AlertTest do %{widget | alert: %{widget.alert | informed_entities: ies}} end - defp put_stop_sequences(widget, sequences) do + defp put_tagged_stop_sequences(widget, tagged_sequences) do + sequences = RoutePattern.untag_stop_sequences(tagged_sequences) + %{ widget | location_context: %{ widget.location_context - | stop_sequences: sequences, + | tagged_stop_sequences: tagged_sequences, upstream_stops: Stop.upstream_stop_id_set(widget.location_context.home_stop, sequences), downstream_stops: @@ -93,16 +96,20 @@ defmodule Screens.V2.WidgetInstance.AlertTest do %{widget: put_home_stop(widget, BusShelter, home_stop)} end - defp setup_stop_sequences(%{widget: widget}) do - stop_sequences = [ - ~w[0 1 2 3 4 5 6 7 8 9], - ~w[10 20 30 4 5 7], - ~w[ 5 6 90], - ~w[200 40 5], - ~w[111 222 333] - ] + defp setup_tagged_stop_sequences(%{widget: widget}) do + tagged_stop_sequences = %{ + "A" => [ + ~w[0 1 2 3 4 5 6 7 8 9], + ~w[10 20 30 4 5 7] + ], + "B" => [ + ~w[ 5 6 90], + ~w[200 40 5], + ~w[111 222 333] + ] + } - %{widget: put_stop_sequences(widget, stop_sequences)} + %{widget: put_tagged_stop_sequences(widget, tagged_stop_sequences)} end defp setup_routes(%{widget: widget}) do @@ -118,7 +125,7 @@ defmodule Screens.V2.WidgetInstance.AlertTest do defp setup_location_context(%{widget: widget}) do %{widget: widget} |> setup_home_stop() - |> setup_stop_sequences() + |> setup_tagged_stop_sequences() |> setup_routes() end diff --git a/test/screens/v2/widget_instance/dup_special_case_alert_test.exs b/test/screens/v2/widget_instance/dup_special_case_alert_test.exs index dcbd71b03..e4e945279 100644 --- a/test/screens/v2/widget_instance/dup_special_case_alert_test.exs +++ b/test/screens/v2/widget_instance/dup_special_case_alert_test.exs @@ -4,6 +4,7 @@ defmodule Screens.V2.WidgetInstance.DupSpecialCaseAlertTest do alias ScreensConfig.Screen alias ScreensConfig.V2.{Alerts, Departures, Dup, FreeTextLine} alias Screens.LocationContext + alias Screens.RoutePatterns.RoutePattern alias Screens.Stops.Stop alias Screens.V2.CandidateGenerator.Dup.Alerts, as: DupAlerts alias Screens.V2.WidgetInstance.DupSpecialCaseAlert @@ -66,101 +67,114 @@ defmodule Screens.V2.WidgetInstance.DupSpecialCaseAlertTest do ] end - defp stop_sequences("place-kencl") do - [ - [ - "place-unsqu", - "place-lech", - "place-spmnl", - "place-north", - "place-haecl", - "place-gover", - "place-pktrm", - "place-boyls", - "place-armnl", - "place-coecl", - "place-hymnl", - "place-kencl", - "place-fenwy", - "place-longw", - "place-bvmnl", - "place-brkhl", - "place-bcnfd", - "place-rsmnl", - "place-chhil", - "place-newto", - "place-newtn", - "place-eliot", - "place-waban", - "place-woodl", - "place-river" + defp tagged_stop_sequences("place-kencl") do + %{ + "Green-B" => [ + [ + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl", + "place-coecl", + "place-hymnl", + "place-kencl", + "place-bland", + "place-buest", + "place-bucen", + "place-amory", + "place-babck", + "place-brico", + "place-harvd", + "place-grigg", + "place-alsgr", + "place-wrnst", + "place-wascm", + "place-sthld", + "place-chswk", + "place-chill", + "place-sougr", + "place-lake" + ] ], - [ - "place-gover", - "place-pktrm", - "place-boyls", - "place-armnl", - "place-coecl", - "place-hymnl", - "place-kencl", - "place-bland", - "place-buest", - "place-bucen", - "place-amory", - "place-babck", - "place-brico", - "place-harvd", - "place-grigg", - "place-alsgr", - "place-wrnst", - "place-wascm", - "place-sthld", - "place-chswk", - "place-chill", - "place-sougr", - "place-lake" + "Green-C" => [ + [ + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl", + "place-coecl", + "place-hymnl", + "place-kencl", + "place-smary", + "place-hwsst", + "place-kntst", + "place-stpul", + "place-cool", + "place-sumav", + "place-bndhl", + "place-fbkst", + "place-bcnwa", + "place-tapst", + "place-denrd", + "place-engav", + "place-clmnl" + ] ], - [ - "place-gover", - "place-pktrm", - "place-boyls", - "place-armnl", - "place-coecl", - "place-hymnl", - "place-kencl", - "place-smary", - "place-hwsst", - "place-kntst", - "place-stpul", - "place-cool", - "place-sumav", - "place-bndhl", - "place-fbkst", - "place-bcnwa", - "place-tapst", - "place-denrd", - "place-engav", - "place-clmnl" + "Green-D" => [ + [ + "place-unsqu", + "place-lech", + "place-spmnl", + "place-north", + "place-haecl", + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl", + "place-coecl", + "place-hymnl", + "place-kencl", + "place-fenwy", + "place-longw", + "place-bvmnl", + "place-brkhl", + "place-bcnfd", + "place-rsmnl", + "place-chhil", + "place-newto", + "place-newtn", + "place-eliot", + "place-waban", + "place-woodl", + "place-river" + ] ] - ] + } end - defp stop_sequences("place-wtcst") do - [ - [ - "place-sstat", - "place-crtst", - "place-wtcst", - "place-conrd", - "place-aport", - "place-estav", - "place-boxdt", - "place-belsq", - "place-chels" + defp tagged_stop_sequences("place-wtcst") do + %{ + # SL1 + "741" => [["place-sstat", "place-crtst", "place-wtcst", "place-conrd", "17091"]], + # SL2 + "742" => [ + ["place-sstat", "place-crtst", "place-wtcst", "place-conrd", "247", "30249", "30250"] ], - ["place-sstat", "place-crtst", "place-wtcst", "place-conrd", "247", "30249", "30250"], - ["place-sstat", "place-crtst", "place-wtcst", "place-conrd", "17091"] - ] + # SL3 + "743" => [ + [ + "place-sstat", + "place-crtst", + "place-wtcst", + "place-conrd", + "place-aport", + "place-estav", + "place-boxdt", + "place-belsq", + "place-chels" + ] + ] + } end describe "dup alert_instances/6 > serialize/1" do @@ -900,12 +914,15 @@ defmodule Screens.V2.WidgetInstance.DupSpecialCaseAlertTest do wtc_alerts: wtc_alerts, fetch_location_context_fn: fn _, stop_id, _ -> + tagged_stop_sequences = tagged_stop_sequences(stop_id) + stop_sequences = RoutePattern.untag_stop_sequences(tagged_stop_sequences) + {:ok, %LocationContext{ home_stop: stop_id, - stop_sequences: stop_sequences(stop_id), - upstream_stops: Stop.upstream_stop_id_set(stop_id, stop_sequences(stop_id)), - downstream_stops: Stop.downstream_stop_id_set(stop_id, stop_sequences(stop_id)), + tagged_stop_sequences: tagged_stop_sequences, + upstream_stops: Stop.upstream_stop_id_set(stop_id, stop_sequences), + downstream_stops: Stop.downstream_stop_id_set(stop_id, stop_sequences), routes: routes(stop_id), alert_route_types: Stop.get_route_type_filter(Dup, stop_id) }} diff --git a/test/screens/v2/widget_instance/elevator_status_test.exs b/test/screens/v2/widget_instance/elevator_status_test.exs index 8aec9e9e4..87427f546 100644 --- a/test/screens/v2/widget_instance/elevator_status_test.exs +++ b/test/screens/v2/widget_instance/elevator_status_test.exs @@ -6,6 +6,7 @@ defmodule Screens.V2.WidgetInstance.ElevatorStatusTest do alias ScreensConfig.Screen alias ScreensConfig.V2.{ElevatorStatus, PreFare} alias Screens.LocationContext + alias Screens.RoutePatterns.RoutePattern alias Screens.Stops.Stop # Convenience function to build an elevator alert @@ -57,10 +58,14 @@ defmodule Screens.V2.WidgetInstance.ElevatorStatusTest do "place-qux" => "Qux Station" } - stop_sequences = [ - ["place-foo", "place-bar"], - ["place-foo", "place-bar"] - ] + tagged_stop_sequences = %{ + "A" => [ + ["place-foo", "place-bar"], + ["place-foo", "place-bar"] + ] + } + + stop_sequences = RoutePattern.untag_stop_sequences(tagged_stop_sequences) station_id_to_icons = %{ "place-foo" => [:red], @@ -71,7 +76,7 @@ defmodule Screens.V2.WidgetInstance.ElevatorStatusTest do location_context = %LocationContext{ home_stop: home_station_id, - stop_sequences: stop_sequences, + tagged_stop_sequences: tagged_stop_sequences, upstream_stops: Stop.upstream_stop_id_set(home_station_id, stop_sequences), downstream_stops: Stop.downstream_stop_id_set(home_station_id, stop_sequences), routes: [], diff --git a/test/screens/v2/widget_instance/reconstructed_alert_property_test.exs b/test/screens/v2/widget_instance/reconstructed_alert_property_test.exs index 0c23d2439..b09ecab1a 100644 --- a/test/screens/v2/widget_instance/reconstructed_alert_property_test.exs +++ b/test/screens/v2/widget_instance/reconstructed_alert_property_test.exs @@ -14,6 +14,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertPropertyTest do alias ScreensConfig.V2.{PreFare} alias ScreensConfig.V2.Header.CurrentStopId alias Screens.LocationContext + alias Screens.RoutePatterns.RoutePattern alias Screens.Stops.Stop alias Screens.Util alias Screens.V2.CandidateGenerator @@ -1139,8 +1140,10 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertPropertyTest do } end) - station_sequences = - Enum.map(route_ids_at_stop, fn id -> Stop.get_route_stop_sequence(id) end) + tagged_station_sequences = + Map.new(route_ids_at_stop, fn id -> {id, [Stop.get_route_stop_sequence(id)]} end) + + station_sequences = RoutePattern.untag_stop_sequences(tagged_station_sequences) fetch_alerts_fn = fn _ -> {:ok, [alert]} end fetch_stop_name_fn = fn _ -> "Test" end @@ -1149,7 +1152,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertPropertyTest do {:ok, %LocationContext{ home_stop: stop_id, - stop_sequences: station_sequences, + tagged_stop_sequences: tagged_station_sequences, upstream_stops: Stop.upstream_stop_id_set(stop_id, station_sequences), downstream_stops: Stop.downstream_stop_id_set(stop_id, station_sequences), routes: routes_at_stop, @@ -1166,8 +1169,12 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertPropertyTest do fetch_location_context_fn ) + # We can't build disruption diagrams for some of these alert scenarios. + # Prevent `ReconstructedAlert.serialize` from filling the console with log noise when this happens. + fake_log = fn _message -> nil end + Enum.each(alert_widgets, fn widget -> - assert %{issue: _, location: _, routes: _} = ReconstructedAlert.serialize(widget) + assert %{issue: _, location: _} = ReconstructedAlert.serialize(widget, fake_log) end) end end diff --git a/test/screens/v2/widget_instance/reconstructed_alert_test.exs b/test/screens/v2/widget_instance/reconstructed_alert_test.exs index 0f1c45515..54bd90e19 100644 --- a/test/screens/v2/widget_instance/reconstructed_alert_test.exs +++ b/test/screens/v2/widget_instance/reconstructed_alert_test.exs @@ -6,6 +6,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do alias ScreensConfig.V2.{PreFare} alias ScreensConfig.V2.Header.CurrentStopId alias Screens.LocationContext + alias Screens.RoutePatterns.RoutePattern alias Screens.Stops.Stop alias Screens.V2.AlertsWidget alias Screens.V2.CandidateGenerator @@ -18,11 +19,11 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do defp setup_base(_context) do %{ widget: %ReconstructedAlert{ - alert: %Alert{id: "123"}, + alert: %Alert{id: "123", updated_at: ~U[2023-06-09T09:00:00Z]}, screen: %Screen{app_params: nil, vendor: nil, device_id: nil, name: nil, app_id: nil}, location_context: %LocationContext{ home_stop: nil, - stop_sequences: nil, + tagged_stop_sequences: nil, upstream_stops: nil, downstream_stops: nil, routes: nil, @@ -51,12 +52,14 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do %{widget | alert: %{widget.alert | informed_entities: ies}} end - defp put_stop_sequences(widget, sequences) do + defp put_tagged_stop_sequences(widget, tagged_sequences) do + sequences = RoutePattern.untag_stop_sequences(tagged_sequences) + %{ widget | location_context: %{ widget.location_context - | stop_sequences: sequences, + | tagged_stop_sequences: tagged_sequences, upstream_stops: Stop.upstream_stop_id_set(widget.location_context.home_stop, sequences), downstream_stops: @@ -75,8 +78,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do } end - defp put_informed_stations_string(widget, string) do - %{widget | informed_stations_string: string} + defp put_informed_stations(widget, stations) do + %{widget | informed_stations: stations} end defp put_app_id(widget, app_id) do @@ -107,6 +110,10 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do %{widget | is_terminal_station: is_terminal_station} end + defp put_is_full_screen(widget, is_full_screen) do + %{widget | is_full_screen: is_full_screen} + end + defp ie(opts) do %{ stop: opts[:stop], @@ -116,6 +123,100 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do } end + defp setup_transfer_station(%{widget: widget}) do + home_stop = "place-dwnxg" + + tagged_stop_sequences = %{ + "Orange" => [ + [ + "place-ogmnl", + "place-haecl", + "place-state", + "place-dwnxg", + "place-chncl", + "place-forhl" + ] + ], + "Red" => [ + [ + "place-alfcl", + "place-pktrm", + "place-dwnxg", + "place-asmnl" + ], + [ + "place-alfcl", + "place-dwnxg", + "place-sstat", + "place-brntn" + ] + ] + } + + routes = [ + %{ + route_id: "Red", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + }, + %{ + route_id: "Orange", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + } + ] + + widget = + widget + |> put_home_stop(PreFare, home_stop) + |> put_tagged_stop_sequences(tagged_stop_sequences) + |> put_informed_stations(["Downtown Crossing"]) + |> put_routes_at_stop(routes) + + %{widget: widget} + end + + defp setup_single_line_station(%{widget: widget}) do + home_stop = "place-mlmnl" + + tagged_stop_sequences = %{ + "Orange" => [ + [ + "place-ogmnl", + "place-mlmnl", + "place-welln", + "place-astao" + ] + ] + } + + routes = [ + %{ + route_id: "Orange", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + } + ] + + widget = + widget + |> put_home_stop(PreFare, home_stop) + |> put_tagged_stop_sequences(tagged_stop_sequences) + |> put_informed_stations(["Malden Center"]) + |> put_routes_at_stop(routes) + + %{widget: widget} + end + # Setting up screen location context defp setup_home_stop(%{widget: widget}) do home_stop = "place-dwnxg" @@ -123,39 +224,43 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do %{widget: put_home_stop(widget, PreFare, home_stop)} end - defp setup_stop_sequences(%{widget: widget}) do - stop_sequences = [ - [ - "place-ogmnl", - "place-dwnxg", - "place-chncl", - "place-forhl" - ], - [ - "place-asmnl", - "place-dwnxg", - "place-pktrm", - "place-alfcl" + defp setup_tagged_stop_sequences(%{widget: widget}) do + tagged_stop_sequences = %{ + "Orange" => [ + [ + "place-ogmnl", + "place-dwnxg", + "place-chncl", + "place-forhl" + ] ], - [ - "place-alfcl", - "place-dwnxg", - "place-sstat", - "place-brntn" + "Red" => [ + [ + "place-asmnl", + "place-dwnxg", + "place-pktrm", + "place-alfcl" + ], + [ + "place-alfcl", + "place-dwnxg", + "place-sstat", + "place-brntn" + ] ] - ] + } - %{widget: put_stop_sequences(widget, stop_sequences)} + %{widget: put_tagged_stop_sequences(widget, tagged_stop_sequences)} end defp setup_informed_entities_string(%{widget: widget}) do - %{widget: put_informed_stations_string(widget, "Alewife")} + %{widget: put_informed_stations(widget, ["Downtown Crossing"])} end defp setup_location_context(%{widget: widget}) do %{widget: widget} |> setup_home_stop() - |> setup_stop_sequences() + |> setup_tagged_stop_sequences() |> setup_routes() end @@ -208,7 +313,23 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do %{widget: put_effect(widget, :station_closure)} end + # We can't build disruption diagrams for some of these alert scenarios. + # Prevent `ReconstructedAlert.serialize` from filling the console with log noise when this happens. + defp fake_log(_message), do: nil + # Pass this to `setup` to set up "context" data on the alert widget, without setting up the API alert itself. + @transfer_stations_alert_widget_context_setup_group [ + :setup_transfer_station, + :setup_screen_config, + :setup_now + ] + + @one_line_station_alert_widget_context_setup_group [ + :setup_single_line_station, + :setup_screen_config, + :setup_now + ] + @alert_widget_context_setup_group [ :setup_location_context, :setup_screen_config, @@ -228,13 +349,20 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do setup @valid_alert_setup_group test "returns takeover for a closure alert at this station", %{widget: widget} do + widget = put_is_full_screen(widget, true) assert [1] == WidgetInstance.priority(widget) assert [:full_body] == WidgetInstance.slot_names(widget) assert :reconstructed_takeover == WidgetInstance.widget_type(widget) end test "returns takeover for a suspension that affects all station trips", %{widget: widget} do - widget = put_informed_entities(widget, [ie(route: "Red"), ie(route: "Orange")]) + widget = + put_informed_entities(widget, [ + ie(route: "Red", route_type: 1), + ie(route: "Orange", route_type: 1) + ]) + |> put_is_full_screen(true) + assert [1] == WidgetInstance.priority(widget) assert [:full_body] == WidgetInstance.slot_names(widget) assert :reconstructed_takeover == WidgetInstance.widget_type(widget) @@ -243,7 +371,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do test "returns flex zone alert for a suspension that affects some station trips", %{ widget: widget } do - widget = put_informed_entities(widget, [ie(route: "Red")]) + widget = put_informed_entities(widget, [ie(route: "Red", route_type: 1)]) assert [3] == WidgetInstance.priority(widget) assert [:large] == WidgetInstance.slot_names(widget) assert :reconstructed_large_alert == WidgetInstance.widget_type(widget) @@ -262,15 +390,18 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do |> put_home_stop(PreFare, "place-forhl") |> put_informed_entities([ie(stop: "place-chncl"), ie(stop: "place-forhl")]) |> put_effect(:suspension) - |> put_stop_sequences([ - [ - "place-ogmnl", - "place-dwnxg", - "place-chncl", - "place-forhl" + |> put_tagged_stop_sequences(%{ + "Orange" => [ + [ + "place-ogmnl", + "place-dwnxg", + "place-chncl", + "place-forhl" + ] ] - ]) + }) |> put_is_terminal_station(true) + |> put_is_full_screen(true) assert [1] == WidgetInstance.priority(widget) assert [:full_body] == WidgetInstance.slot_names(widget) @@ -283,15 +414,18 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do |> put_home_stop(PreFare, "place-forhl") |> put_informed_entities([ie(stop: "place-chncl"), ie(stop: "place-forhl")]) |> put_effect(:shuttle) - |> put_stop_sequences([ - [ - "place-ogmnl", - "place-dwnxg", - "place-chncl", - "place-forhl" + |> put_tagged_stop_sequences(%{ + "Orange" => [ + [ + "place-ogmnl", + "place-dwnxg", + "place-chncl", + "place-forhl" + ] ] - ]) + }) |> put_is_terminal_station(true) + |> put_is_full_screen(true) assert [1] == WidgetInstance.priority(widget) assert [:full_body] == WidgetInstance.slot_names(widget) @@ -327,14 +461,16 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do |> put_home_stop(PreFare, "place-forhl") |> put_informed_entities([ie(stop: "place-chncl"), ie(stop: "place-forhl")]) |> put_effect(:severe_delay) - |> put_stop_sequences([ - [ - "place-ogmnl", - "place-dwnxg", - "place-chncl", - "place-forhl" + |> put_tagged_stop_sequences(%{ + "Orange" => [ + [ + "place-ogmnl", + "place-dwnxg", + "place-chncl", + "place-forhl" + ] ] - ]) + }) |> put_is_terminal_station(true) assert [3] == WidgetInstance.priority(widget) @@ -343,61 +479,86 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do end end - describe "serialize_takeover_alert/2" do - setup @alert_widget_context_setup_group ++ [:setup_active_period] + describe "serialize_dual_screen_alert/1 single line station" do + setup @one_line_station_alert_widget_context_setup_group ++ [:setup_active_period] test "handles suspension", %{widget: widget} do widget = widget |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red"), - ie(stop: "place-dwnxg", route: "Orange") + ie(stop: "place-mlmnl", route: "Orange", route_type: 1), + ie(stop: "place-welln", route: "Orange", route_type: 1) ]) |> put_cause(:unknown) + |> put_is_full_screen(true) expected = %{ - issue: %{icon: nil, text: ["No", %{route: "red"}, %{route: "orange"}, "trains"]}, - location: "at Downtown Crossing", - cause: "", - routes: [ - %{color: :orange, text: "OL", type: :text}, - %{color: :red, text: "RL", type: :text} - ], + issue: "No trains to Forest Hills", + location: "No Orange Line trains between Malden Center and Wellington", + cause: nil, effect: :suspension, - urgent: true, - region: :inside, - remedy: "Seek alternate route" + remedy: "Seek alternate route", + updated_at: "Friday, 5:00 am", + routes: [%{route_id: "Orange", svg_name: "ol"}], + endpoints: {"Malden Center", "Wellington"}, + disruption_diagram: %{ + current_station_slot_index: 1, + effect: :suspension, + effect_region_slot_index_range: {1, 2}, + line: :orange, + slots: [ + %{label_id: "place-ogmnl", type: :terminal}, + %{label: %{abbrev: "Malden Ctr", full: "Malden Center"}, show_symbol: true}, + %{label: %{abbrev: "Wellington", full: "Wellington"}, show_symbol: true}, + %{label_id: "place-astao", type: :terminal} + ] + }, + is_transfer_station: false, + region: :boundary } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles shuttle", %{widget: widget} do widget = widget + |> put_home_stop(PreFare, "place-welln") |> put_effect(:shuttle) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red"), - ie(stop: "place-dwnxg", route: "Orange") + ie(stop: "place-welln", route: "Orange", route_type: 1), + ie(stop: "place-astao", route: "Orange", route_type: 1) ]) |> put_cause(:unknown) + |> put_is_full_screen(true) expected = %{ - issue: %{icon: nil, text: ["No", %{route: "red"}, %{route: "orange"}, "trains"]}, - location: "at Downtown Crossing", - cause: "", - routes: [ - %{color: :orange, text: "OL", type: :text}, - %{color: :red, text: "RL", type: :text} - ], + issue: "No trains to Forest Hills", + location: "Shuttle buses between Wellington and Assembly", + cause: nil, effect: :shuttle, - urgent: true, - region: :inside, - remedy: "Use shuttle bus" + remedy: "Use shuttle bus", + updated_at: "Friday, 5:00 am", + routes: [%{route_id: "Orange", svg_name: "ol"}], + endpoints: {"Wellington", "Assembly"}, + region: :boundary, + is_transfer_station: false, + disruption_diagram: %{ + current_station_slot_index: 2, + effect: :shuttle, + effect_region_slot_index_range: {2, 3}, + line: :orange, + slots: [ + %{label_id: "place-ogmnl", type: :terminal}, + %{label: %{abbrev: "Malden Ctr", full: "Malden Center"}, show_symbol: true}, + %{label: %{abbrev: "Wellington", full: "Wellington"}, show_symbol: true}, + %{label_id: "place-astao", type: :terminal} + ] + } } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles station closure", %{widget: widget} do @@ -405,217 +566,595 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:station_closure) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red"), - ie(stop: "place-dwnxg", route: "Orange") + ie(stop: "place-mlmnl", route: "Orange", route_type: 1) ]) |> put_cause(:unknown) + |> put_is_full_screen(true) expected = %{ - issue: "Station Closed", - location: "", - cause: "", - routes: [ - %{color: :orange, text: "OL", type: :text}, - %{color: :red, text: "RL", type: :text} - ], + issue: "Station closed", + location: %ScreensConfig.V2.FreeTextLine{ + icon: nil, + text: ["Orange Line trains skip ", %{format: :nowrap, text: "Malden Center"}] + }, + cause: nil, effect: :station_closure, - urgent: true, - region: :inside, - remedy: "Seek alternate route" + remedy: "Seek alternate route", + updated_at: "Friday, 5:00 am", + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], + other_closures: ["Malden Center"], + disruption_diagram: %{ + effect: :station_closure, + closed_station_slot_indices: [1], + line: :orange, + current_station_slot_index: 1, + slots: [ + %{type: :terminal, label_id: "place-ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{type: :terminal, label_id: "place-astao"} + ] + } } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles alert with cause", %{widget: widget} do widget = widget + |> put_home_stop(PreFare, "place-welln") |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red"), - ie(stop: "place-dwnxg", route: "Orange") + ie(stop: "place-welln", route: "Orange", route_type: 1), + ie(stop: "place-astao", route: "Orange", route_type: 1) ]) |> put_cause(:construction) + |> put_is_full_screen(true) expected = %{ - issue: %{icon: nil, text: ["No", %{route: "red"}, %{route: "orange"}, "trains"]}, - location: "at Downtown Crossing", - cause: "Due to construction", - routes: [ - %{color: :orange, text: "OL", type: :text}, - %{color: :red, text: "RL", type: :text} - ], + issue: "No trains to Forest Hills", + location: "No Orange Line trains between Wellington and Assembly", + cause: :construction, effect: :suspension, - urgent: true, - region: :inside, - remedy: "Seek alternate route" + remedy: "Seek alternate route", + updated_at: "Friday, 5:00 am", + routes: [%{route_id: "Orange", svg_name: "ol"}], + endpoints: {"Wellington", "Assembly"}, + disruption_diagram: %{ + current_station_slot_index: 2, + effect: :suspension, + effect_region_slot_index_range: {2, 3}, + line: :orange, + slots: [ + %{label_id: "place-ogmnl", type: :terminal}, + %{label: %{abbrev: "Malden Ctr", full: "Malden Center"}, show_symbol: true}, + %{label: %{abbrev: "Wellington", full: "Wellington"}, show_symbol: true}, + %{label_id: "place-astao", type: :terminal} + ] + }, + is_transfer_station: false, + region: :boundary } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles terminal boundary suspension", %{widget: widget} do widget = widget - |> put_home_stop(PreFare, "place-forhl") |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: "place-grnst", route: "Orange", direction_id: 1), - ie(stop: "place-forhl", route: "Orange", direction_id: 1) + ie(stop: "place-ogmnl", route: "Orange", direction_id: 1, route_type: 1), + ie(stop: "place-mlmnl", route: "Orange", direction_id: 1, route_type: 1) ]) |> put_cause(:unknown) - |> put_stop_sequences([ - [ - "place-jaksn", - "place-sbmnl", - "place-grnst", - "place-forhl" + |> put_is_terminal_station(true) + |> put_is_full_screen(true) + + expected = %{ + issue: "No trains", + location: "No Orange Line trains between Oak Grove and Malden Center", + cause: nil, + effect: :suspension, + remedy: "Seek alternate route", + updated_at: "Friday, 5:00 am", + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], + endpoints: {"Oak Grove", "Malden Center"}, + disruption_diagram: %{ + effect: :suspension, + effect_region_slot_index_range: {0, 1}, + line: :orange, + current_station_slot_index: 1, + slots: [ + %{type: :terminal, label_id: "place-ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{type: :terminal, label_id: "place-astao"} ] + } + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles terminal boundary shuttle", %{widget: widget} do + widget = + widget + |> put_effect(:shuttle) + |> put_informed_entities([ + ie(stop: "place-ogmnl", route: "Orange", direction_id: 1, route_type: 1), + ie(stop: "place-mlmnl", route: "Orange", direction_id: 1, route_type: 1) ]) + |> put_cause(:unknown) |> put_is_terminal_station(true) - |> put_routes_at_stop([ - %{ - route_id: "Orange", - active?: true, - direction_destinations: nil, - long_name: nil, - short_name: nil, - type: :subway - } + |> put_is_full_screen(true) + + expected = %{ + issue: "No trains", + location: "Shuttle buses replace Orange Line trains between Oak Grove and Malden Center", + cause: nil, + effect: :shuttle, + remedy: "Use shuttle bus", + updated_at: "Friday, 5:00 am", + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], + endpoints: {"Oak Grove", "Malden Center"}, + disruption_diagram: %{ + effect: :shuttle, + effect_region_slot_index_range: {0, 1}, + line: :orange, + current_station_slot_index: 1, + slots: [ + %{type: :terminal, label_id: "place-ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{type: :terminal, label_id: "place-astao"} + ] + } + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + end + + describe "serialize_fullscreen_alert/1 one line" do + setup @one_line_station_alert_widget_context_setup_group ++ [:setup_active_period] + + test "handles boundary suspension", %{widget: widget} do + widget = + widget + |> put_effect(:suspension) + |> put_informed_entities([ + ie(stop: "place-ogmnl", route: "Orange", route_type: 1, direction_id: 1), + ie(stop: "place-mlmnl", route: "Orange", route_type: 1, direction_id: 1) ]) + |> put_cause(:unknown) + |> put_is_full_screen(true) expected = %{ - issue: %{icon: nil, text: ["No", %{route: "orange"}, "trains"]}, - location: "between Green Street and Forest Hills", - cause: "", - routes: [ - %{color: :orange, text: "ORANGE LINE", type: :text} - ], + issue: "No trains to Oak Grove", + location: "No Orange Line trains between Oak Grove and Malden Center", + cause: nil, + routes: [%{route_id: "Orange", svg_name: "ol"}], effect: :suspension, - urgent: true, - region: :inside, - remedy: "Seek alternate route" + remedy: "Seek alternate route", + updated_at: "Friday, 5:00 am", + region: :boundary, + endpoints: {"Oak Grove", "Malden Center"}, + is_transfer_station: false, + disruption_diagram: %{ + effect: :suspension, + effect_region_slot_index_range: {0, 1}, + line: :orange, + current_station_slot_index: 1, + slots: [ + %{type: :terminal, label_id: "place-ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{type: :terminal, label_id: "place-astao"} + ] + } } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end - test "handles terminal boundary shuttle", %{widget: widget} do + test "handles boundary shuttle", %{widget: widget} do widget = widget - |> put_home_stop(PreFare, "place-forhl") |> put_effect(:shuttle) |> put_informed_entities([ - ie(stop: "place-grnst", route: "Orange", direction_id: 1), - ie(stop: "place-forhl", route: "Orange", direction_id: 1) + ie(stop: "place-ogmnl", route: "Orange", route_type: 1, direction_id: 1), + ie(stop: "place-mlmnl", route: "Orange", route_type: 1, direction_id: 1) ]) |> put_cause(:unknown) - |> put_stop_sequences([ - [ - "place-jaksn", - "place-sbmnl", - "place-grnst", - "place-forhl" + |> put_is_full_screen(true) + + expected = %{ + issue: "No trains to Oak Grove", + location: "Shuttle buses between Oak Grove and Malden Center", + cause: nil, + routes: [%{route_id: "Orange", svg_name: "ol"}], + effect: :shuttle, + remedy: "Use shuttle bus", + updated_at: "Friday, 5:00 am", + region: :boundary, + endpoints: {"Oak Grove", "Malden Center"}, + is_transfer_station: false, + disruption_diagram: %{ + effect: :shuttle, + effect_region_slot_index_range: {0, 1}, + line: :orange, + current_station_slot_index: 1, + slots: [ + %{type: :terminal, label_id: "place-ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{type: :terminal, label_id: "place-astao"} ] + } + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles moderate delay", %{widget: widget} do + widget = + widget + |> put_effect(:delay) + |> put_informed_entities([ + ie(stop: "place-mlmnl", route: "Orange", route_type: 1) ]) - |> put_is_terminal_station(true) - |> put_routes_at_stop([ - %{ - route_id: "Orange", - active?: true, - direction_destinations: nil, - long_name: nil, - short_name: nil, - type: :subway - } + |> put_cause(:unknown) + |> put_severity(5) + |> put_alert_header("Delays are happening") + |> put_is_full_screen(true) + + expected = %{ + issue: "Trains may be delayed up to 20 minutes", + cause: nil, + routes: [%{route_id: "Orange", svg_name: "ol"}], + effect: :delay, + remedy: "Delays are happening", + updated_at: "Friday, 5:00 am", + region: :here + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles severe delay", %{widget: widget} do + widget = + widget + |> put_effect(:delay) + |> put_informed_entities([ + ie(stop: "place-mlmnl", route: "Orange", route_type: 1) ]) + |> put_cause(:unknown) + |> put_severity(10) + |> put_is_full_screen(true) + |> put_alert_header("Delays are happening") expected = %{ - issue: %{icon: nil, text: ["No", %{route: "orange"}, "trains"]}, - location: "between Green Street and Forest Hills", - cause: "", - routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], + issue: "Trains may be delayed over 60 minutes", + cause: nil, + routes: [%{route_id: "Orange", svg_name: "ol"}], + effect: :delay, + remedy: "Delays are happening", + updated_at: "Friday, 5:00 am", + region: :here + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles directional delay", %{widget: widget} do + widget = + widget + |> put_effect(:delay) + |> put_informed_entities([ + ie(stop: "place-mlmnl", route: "Orange", route_type: 1, direction_id: 0) + ]) + |> put_cause(:unknown) + |> put_severity(5) + |> put_alert_header("Delays are happening") + |> put_is_full_screen(true) + + expected = %{ + issue: "Trains may be delayed up to 20 minutes", + cause: nil, + routes: [%{headsign: "Forest Hills", route_id: "Orange", svg_name: "ol-forest-hills"}], + effect: :delay, + remedy: "Delays are happening", + updated_at: "Friday, 5:00 am", + region: :here + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles alert with cause", %{widget: widget} do + widget = + widget + |> put_effect(:delay) + |> put_informed_entities([ + ie(stop: "place-mlmnl", route: "Orange", route_type: 1) + ]) + |> put_cause(:construction) + |> put_severity(10) + |> put_is_full_screen(true) + |> put_alert_header("Delays are happening") + + expected = %{ + issue: "Trains may be delayed over 60 minutes", + cause: :construction, + routes: [%{route_id: "Orange", svg_name: "ol"}], + effect: :delay, + remedy: "Delays are happening", + updated_at: "Friday, 5:00 am", + region: :here + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles downstream delay", %{widget: widget} do + widget = + widget + |> put_effect(:delay) + |> put_informed_entities([ + ie(stop: "place-swnxg", route: "Orange", route_type: 1) + ]) + |> put_cause(:unknown) + |> put_severity(10) + |> put_is_full_screen(true) + |> put_alert_header("Delays are happening") + + expected = %{ + issue: "Trains may be delayed over 60 minutes", + cause: nil, + routes: [%{route_id: "Orange", svg_name: "ol"}], + effect: :delay, + remedy: "Delays are happening", + updated_at: "Friday, 5:00 am", + region: :outside + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles downstream shuttle", %{widget: widget} do + widget = + widget + |> put_effect(:shuttle) + |> put_informed_entities([ + ie(stop: "place-welln", route: "Orange", route_type: 1), + ie(stop: "place-astao", route: "Orange", route_type: 1) + ]) + |> put_cause(:unknown) + |> put_is_full_screen(true) + + expected = %{ + issue: "No trains", + location: nil, + cause: nil, + routes: [%{headsign: "Forest Hills", route_id: "Orange", svg_name: "ol-forest-hills"}], effect: :shuttle, - urgent: true, - region: :inside, - remedy: "Use shuttle bus" + remedy: "Shuttle buses available", + updated_at: "Friday, 5:00 am", + region: :outside, + endpoints: {"Wellington", "Assembly"}, + is_transfer_station: false, + disruption_diagram: %{ + current_station_slot_index: 1, + effect: :shuttle, + effect_region_slot_index_range: {2, 3}, + line: :orange, + slots: [ + %{label_id: "place-ogmnl", type: :terminal}, + %{label: %{abbrev: "Malden Ctr", full: "Malden Center"}, show_symbol: true}, + %{label: %{abbrev: "Wellington", full: "Wellington"}, show_symbol: true}, + %{label_id: "place-astao", type: :terminal} + ] + } } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles downstream suspension", %{widget: widget} do + widget = + widget + |> put_effect(:suspension) + |> put_informed_entities([ + ie(stop: "place-welln", route: "Orange", route_type: 1), + ie(stop: "place-astao", route: "Orange", route_type: 1) + ]) + |> put_cause(:unknown) + |> put_is_full_screen(true) + + expected = %{ + issue: "No trains", + location: nil, + cause: nil, + routes: [%{headsign: "Forest Hills", route_id: "Orange", svg_name: "ol-forest-hills"}], + effect: :suspension, + remedy: "Seek alternate route", + updated_at: "Friday, 5:00 am", + region: :outside, + endpoints: {"Wellington", "Assembly"}, + is_transfer_station: false, + disruption_diagram: %{ + effect: :suspension, + effect_region_slot_index_range: {2, 3}, + line: :orange, + current_station_slot_index: 1, + slots: [ + %{type: :terminal, label_id: "place-ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{type: :terminal, label_id: "place-astao"} + ] + } + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end end - describe "serialize_inside_flex_alert/1" do - setup @alert_widget_context_setup_group ++ [:setup_active_period] + describe "serialize_fullscreen_alert/1 transfer station" do + setup @transfer_stations_alert_widget_context_setup_group ++ [:setup_active_period] - test "handles suspension", %{widget: widget} do + test "handles :inside station closure on 1 line", %{widget: widget} do + widget = + widget + |> put_effect(:station_closure) + |> put_informed_entities([ + ie(stop: "place-dwnxg", route: "Orange", route_type: 1) + ]) + |> put_cause(:unknown) + |> put_is_full_screen(true) + + expected = %{ + issue: nil, + unaffected_routes: [%{route_id: "Red", svg_name: "rl"}], + cause: nil, + routes: [%{route_id: "Orange", svg_name: "ol"}], + effect: :station_closure, + updated_at: "Friday, 5:00 am", + region: :here, + disruption_diagram: %{ + effect: :station_closure, + closed_station_slot_indices: [3], + line: :orange, + current_station_slot_index: 3, + slots: [ + %{type: :arrow, label_id: "place-ogmnl"}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + %{type: :arrow, label_id: "place-forhl"} + ] + } + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "handles :inside suspension on 1 line", %{widget: widget} do widget = widget |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red") + ie(stop: "place-chncl", route: "Orange", route_type: 1), + ie(stop: "place-dwnxg", route: "Orange", route_type: 1), + ie(stop: "place-state", route: "Orange", route_type: 1), + ie(stop: "place-haecl", route: "Orange", route_type: 1) ]) |> put_cause(:unknown) + |> put_is_full_screen(true) expected = %{ - issue: "No trains", - location: "", - cause: "", - routes: [%{color: :red, text: "RED LINE", type: :text}], + cause: nil, effect: :suspension, - urgent: true, - region: :inside, - remedy: "Seek alternate route" + endpoints: {"Haymarket", "Chinatown"}, + is_transfer_station: true, + issue: "No Orange Line trains", + location: "No Orange Line trains between Haymarket and Chinatown", + region: :here, + remedy: "Seek alternate route", + routes: [%{route_id: "Orange", svg_name: "ol"}], + updated_at: "Friday, 5:00 am", + disruption_diagram: %{ + current_station_slot_index: 3, + effect: :suspension, + effect_region_slot_index_range: {1, 4}, + line: :orange, + slots: [ + %{label_id: "place-ogmnl", type: :terminal}, + %{label: %{abbrev: "Haymarket", full: "Haymarket"}, show_symbol: true}, + %{label: %{abbrev: "State", full: "State"}, show_symbol: true}, + %{label: %{abbrev: "Downt'n Xng", full: "Downtown Crossing"}, show_symbol: true}, + %{label: %{abbrev: "Chinatown", full: "Chinatown"}, show_symbol: true}, + %{label_id: "place-forhl", type: :terminal} + ] + } } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end - test "handles shuttle", %{widget: widget} do + test "handles :inside shuttle on 1 line", %{widget: widget} do widget = widget |> put_effect(:shuttle) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red") + ie(stop: "place-state", route: "Orange", route_type: 1), + ie(stop: "place-dwnxg", route: "Orange", route_type: 1), + ie(stop: "place-chncl", route: "Orange", route_type: 1) ]) |> put_cause(:unknown) + |> put_is_full_screen(true) expected = %{ - issue: "No trains", - location: "", - cause: "", - routes: [%{color: :red, text: "RED LINE", type: :text}], + issue: "No Orange Line trains", + remedy: "Use shuttle bus", + cause: nil, + location: "Shuttle buses between State and Chinatown", + routes: [%{route_id: "Orange", svg_name: "ol"}], effect: :shuttle, - urgent: true, - region: :inside, - remedy: "Use shuttle bus" + updated_at: "Friday, 5:00 am", + region: :here, + endpoints: {"State", "Chinatown"}, + is_transfer_station: true, + disruption_diagram: %{ + current_station_slot_index: 3, + effect: :shuttle, + effect_region_slot_index_range: {2, 4}, + line: :orange, + slots: [ + %{label_id: "place-ogmnl", type: :terminal}, + %{label: %{abbrev: "Haymarket", full: "Haymarket"}, show_symbol: true}, + %{label: %{abbrev: "State", full: "State"}, show_symbol: true}, + %{label: %{abbrev: "Downt'n Xng", full: "Downtown Crossing"}, show_symbol: true}, + %{label: %{abbrev: "Chinatown", full: "Chinatown"}, show_symbol: true}, + %{label_id: "place-forhl", type: :terminal} + ] + } } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end - test "handles station closure", %{widget: widget} do + test "handles multi line delay", %{widget: widget} do widget = widget - |> put_effect(:station_closure) + |> put_effect(:delay) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red") + ie(stop: "place-dwnxg", route: "Orange", route_type: 1), + ie(stop: "place-dwnxg", route: "Red", route_type: 0) ]) |> put_cause(:unknown) + |> put_severity(5) + |> put_is_full_screen(true) expected = %{ - issue: "Red line platform closed", - location: "", - cause: "", - routes: [%{color: :red, text: "RED LINE", type: :text}], - effect: :station_closure, - urgent: true, - region: :inside, - remedy: "Seek alternate route" + issue: "Trains may be delayed up to 20 minutes", + cause: nil, + routes: [], + effect: :delay, + remedy: nil, + updated_at: "Friday, 5:00 am", + region: :here } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end + end + + describe "serialize_inside_flex_alert/1" do + setup @alert_widget_context_setup_group ++ [:setup_active_period] test "handles moderate delay", %{widget: widget} do widget = @@ -639,7 +1178,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles severe delay", %{widget: widget} do @@ -663,31 +1202,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "" } - assert expected == ReconstructedAlert.serialize(widget) - end - - test "handles alert with cause", %{widget: widget} do - widget = - widget - |> put_effect(:delay) - |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red") - ]) - |> put_cause(:construction) - |> put_severity(10) - - expected = %{ - issue: "Trains may be delayed over 60 minutes", - location: "", - cause: "due to construction", - routes: [%{color: :red, text: "RED LINE", type: :text}], - effect: :severe_delay, - urgent: true, - region: :inside, - remedy: "" - } - - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end end @@ -699,8 +1214,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red", direction_id: 1), - ie(stop: "place-pktrm", route: "Red", direction_id: 1) + ie(stop: "place-dwnxg", route: "Red", direction_id: 1, route_type: 1), + ie(stop: "place-pktrm", route: "Red", direction_id: 1, route_type: 1) ]) |> put_cause(:unknown) @@ -715,7 +1230,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "Seek alternate route" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles shuttle", %{widget: widget} do @@ -723,8 +1238,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:shuttle) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red", direction_id: 1), - ie(stop: "place-pktrm", route: "Red", direction_id: 1) + ie(stop: "place-dwnxg", route: "Red", direction_id: 1, route_type: 1), + ie(stop: "place-pktrm", route: "Red", direction_id: 1, route_type: 1) ]) |> put_cause(:unknown) @@ -739,7 +1254,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "Use shuttle bus" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles moderate delay", %{widget: widget} do @@ -747,8 +1262,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:delay) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red", direction_id: 1), - ie(stop: "place-pktrm", route: "Red", direction_id: 1) + ie(stop: "place-dwnxg", route: "Red", direction_id: 1, route_type: 1), + ie(stop: "place-pktrm", route: "Red", direction_id: 1, route_type: 1) ]) |> put_cause(:unknown) |> put_severity(5) @@ -765,7 +1280,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles severe delay", %{widget: widget} do @@ -773,8 +1288,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:delay) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red", direction_id: 1), - ie(stop: "place-pktrm", route: "Red", direction_id: 1) + ie(stop: "place-dwnxg", route: "Red", direction_id: 1, route_type: 1), + ie(stop: "place-pktrm", route: "Red", direction_id: 1, route_type: 1) ]) |> put_cause(:unknown) |> put_severity(10) @@ -790,7 +1305,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles alert with cause", %{widget: widget} do @@ -798,8 +1313,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:delay) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red", direction_id: 1), - ie(stop: "place-pktrm", route: "Red", direction_id: 1) + ie(stop: "place-dwnxg", route: "Red", direction_id: 1, route_type: 1), + ie(stop: "place-pktrm", route: "Red", direction_id: 1, route_type: 1) ]) |> put_cause(:construction) |> put_severity(10) @@ -815,32 +1330,34 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end end describe "serialize_outside_alert/1" do - setup @alert_widget_context_setup_group ++ [:setup_active_period] + setup @one_line_station_alert_widget_context_setup_group ++ [:setup_active_period] test "handles downstream suspension at one stop", %{widget: widget} do widget = widget |> put_effect(:suspension) - |> put_informed_entities([ie(stop: "place-alfcl", direction_id: 1, route: "Red")]) + |> put_informed_entities([ + ie(stop: "place-welln", direction_id: 1, route: "Orange", route_type: 1) + ]) |> put_cause(:unknown) expected = %{ - issue: "No Alewife trains", - location: "at Alewife", + issue: "No Oak Grove trains", + location: "at Wellington", cause: "", - routes: [%{color: :red, text: "RED LINE", type: :text}], + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], effect: :suspension, urgent: false, region: :outside, remedy: "Seek alternate route" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles downstream suspension range", %{widget: widget} do @@ -848,23 +1365,23 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: "place-alfcl", route: "Red"), - ie(stop: "place-davis", route: "Red") + ie(stop: "place-welln", route: "Orange", route_type: 1), + ie(stop: "place-astao", route: "Orange", route_type: 1) ]) |> put_cause(:unknown) expected = %{ issue: "No trains", - location: "between Alewife and Davis", + location: "between Wellington and Assembly", cause: "", - routes: [%{color: :red, text: "RED LINE", type: :text}], + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], effect: :suspension, urgent: false, region: :outside, remedy: "Seek alternate route" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles downstream suspension range, one direction only", %{widget: widget} do @@ -872,44 +1389,46 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: "place-alfcl", direction_id: 1, route: "Red"), - ie(stop: "place-davis", direction_id: 1, route: "Red") + ie(stop: "place-welln", direction_id: 1, route: "Orange", route_type: 1), + ie(stop: "place-astao", direction_id: 1, route: "Orange", route_type: 1) ]) |> put_cause(:unknown) expected = %{ - issue: "No Alewife trains", - location: "between Alewife and Davis", + issue: "No Oak Grove trains", + location: "between Wellington and Assembly", cause: "", - routes: [%{color: :red, text: "RED LINE", type: :text}], + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], effect: :suspension, urgent: false, region: :outside, remedy: "Seek alternate route" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles shuttle at one stop", %{widget: widget} do widget = widget |> put_effect(:shuttle) - |> put_informed_entities([ie(stop: "place-alfcl", direction_id: 1, route: "Red")]) + |> put_informed_entities([ + ie(stop: "place-welln", direction_id: 1, route: "Orange", route_type: 1) + ]) |> put_cause(:unknown) expected = %{ - issue: "No Alewife trains", - location: "at Alewife", + issue: "No Oak Grove trains", + location: "at Wellington", cause: "", - routes: [%{color: :red, text: "RED LINE", type: :text}], + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], effect: :shuttle, urgent: false, region: :outside, remedy: "Use shuttle bus" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles station closure", %{widget: widget} do @@ -917,18 +1436,17 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:station_closure) |> put_informed_entities([ - ie(stop: "place-alfcl", route: "Red"), - ie(stop: "place-alfcl", route: "Orange") + ie(stop: "place-welln", route: "Orange", route_type: 1) ]) |> put_cause(:unknown) + |> put_informed_stations(["Wellington"]) expected = %{ - issue: "Trains will bypass Alewife", + issue: "Trains will bypass Wellington", location: "", cause: "", routes: [ - %{color: :orange, text: "OL", type: :text}, - %{color: :red, text: "RL", type: :text} + %{color: :orange, text: "ORANGE LINE", type: :text} ], effect: :station_closure, urgent: false, @@ -936,7 +1454,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "Seek alternate route" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles delay", %{widget: widget} do @@ -944,7 +1462,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:delay) |> put_informed_entities([ - ie(stop: "place-alfcl", route: "Red") + ie(stop: "place-welln", route: "Orange", route_type: 1) ]) |> put_cause(:unknown) |> put_alert_header("Test Alert") @@ -953,14 +1471,14 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do issue: "Test Alert", location: "", cause: "", - routes: [%{color: :red, text: "RED LINE", type: :text}], + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], effect: :delay, urgent: false, region: :outside, remedy: "" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end test "handles alert with cause", %{widget: widget} do @@ -968,18 +1486,17 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:station_closure) |> put_informed_entities([ - ie(stop: "place-alfcl", route: "Red"), - ie(stop: "place-alfcl", route: "Orange") + ie(stop: "place-welln", route: "Orange", route_type: 1) ]) |> put_cause(:construction) + |> put_informed_stations(["Wellington"]) expected = %{ - issue: "Trains will bypass Alewife", + issue: "Trains will bypass Wellington", location: "", cause: "due to construction", routes: [ - %{color: :orange, text: "OL", type: :text}, - %{color: :red, text: "RL", type: :text} + %{color: :orange, text: "ORANGE LINE", type: :text} ], effect: :station_closure, urgent: false, @@ -987,7 +1504,59 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "Seek alternate route" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "two screen fallback layout for an alert that violates assumptions (suspension that's only 1 stop)", + %{widget: widget} do + widget = + widget + |> put_effect(:suspension) + |> put_alert_header("Simulation of PIO text") + |> put_informed_entities([ + ie(stop: "place-mlmnl", route: "Orange", route_type: 1) + ]) + |> put_cause(:unknown) + |> put_is_full_screen(true) + + expected = %{ + cause: nil, + effect: :suspension, + issue: "No trains", + location: "Simulation of PIO text", + remedy: "Seek alternate route", + routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], + updated_at: "Friday, 5:00 am" + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "one screen fallback layout for an alert that violates assumptions (suspension that's only 1 stop)", + %{widget: widget} do + widget = + widget + |> put_effect(:suspension) + |> put_alert_header("Simulation of PIO text") + |> put_informed_entities([ + ie(stop: "place-welln", route: "Orange", route_type: 1) + ]) + |> put_cause(:unknown) + |> put_is_full_screen(true) + + expected = %{ + cause: nil, + effect: :suspension, + issue: nil, + location: nil, + remedy: nil, + routes: [%{headsign: "Forest Hills", route_id: "Orange", svg_name: "ol-forest-hills"}], + updated_at: "Friday, 5:00 am", + region: :outside, + remedy_bold: "Simulation of PIO text" + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end end @@ -1000,21 +1569,32 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do |> put_home_stop(PreFare, "place-gover") |> put_effect(:shuttle) |> put_informed_entities([ - ie(stop: "place-north", route: "Green-B"), - ie(stop: "place-north", route: "Green-C"), - ie(stop: "place-north", route: "Green-D"), - ie(stop: "place-north", route: "Green-E"), - ie(stop: "place-spmnl", route: "Green-E") + ie(stop: "place-north", route: "Green-B", route_type: 0), + ie(stop: "place-north", route: "Green-C", route_type: 0), + ie(stop: "place-north", route: "Green-D", route_type: 0), + ie(stop: "place-north", route: "Green-E", route_type: 0), + ie(stop: "place-spmnl", route: "Green-D", route_type: 0), + ie(stop: "place-spmnl", route: "Green-E", route_type: 0) ]) |> put_cause(:unknown) - |> put_stop_sequences([ - [ - "place-smpmnl", - "place-north", - "place-haecl", - "place-gover" + |> put_tagged_stop_sequences(%{ + "Green-D" => [ + [ + "place-spmnl", + "place-north", + "place-haecl", + "place-gover" + ] + ], + "Green-E" => [ + [ + "place-spmnl", + "place-north", + "place-haecl", + "place-gover" + ] ] - ]) + }) |> put_routes_at_stop([ %{ route_id: "Green-D", @@ -1036,7 +1616,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do expected = %{ issue: "No trains", - location: "between Science Park/West End and North Station", + location: "between North Station and Science Park/West End", cause: "", routes: [%{color: :green, text: "GREEN LINE", type: :text}], effect: :shuttle, @@ -1045,28 +1625,247 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "Use shuttle bus" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + end + + describe "endpoint reversal" do + setup [:setup_screen_config, :setup_now, :setup_active_period] + + test "reverses endpoints for BL alerts", %{widget: widget} do + widget = + widget + |> put_home_stop(PreFare, "place-mvbcl") + |> put_effect(:shuttle) + |> put_informed_entities([ + ie(stop: "place-orhte", route: "Blue", route_type: 0), + ie(stop: "place-wimnl", route: "Blue", route_type: 0), + ie(stop: "place-aport", route: "Blue", route_type: 0) + ]) + |> put_cause(:unknown) + |> put_tagged_stop_sequences(%{ + "Blue" => [ + [ + "place-wondl", + "place-rbmnl", + "place-bmmnl", + "place-sdmnl", + "place-orhte", + "place-wimnl", + "place-aport", + "place-mvbcl", + "place-aqucl", + "place-state", + "place-gover", + "place-bomnl" + ] + ] + }) + |> put_routes_at_stop([ + %{ + route_id: "Blue", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + } + ]) + + expected = %{ + issue: "No trains", + location: "between Airport and Orient Heights", + cause: "", + routes: [%{color: :blue, text: "BLUE LINE", type: :text}], + effect: :shuttle, + urgent: false, + region: :outside, + remedy: "Use shuttle bus" + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "reverses endpoints for GL trunk alert", %{widget: widget} do + widget = + widget + |> put_home_stop(PreFare, "place-gover") + |> put_effect(:suspension) + |> put_informed_entities([ + ie(stop: "place-pktrm", route: "Green-B", route_type: 0), + ie(stop: "place-pktrm", route: "Green-C", route_type: 0), + ie(stop: "place-pktrm", route: "Green-D", route_type: 0), + ie(stop: "place-pktrm", route: "Green-E", route_type: 0), + ie(stop: "place-boyls", route: "Green-B", route_type: 0), + ie(stop: "place-boyls", route: "Green-C", route_type: 0), + ie(stop: "place-boyls", route: "Green-D", route_type: 0), + ie(stop: "place-boyls", route: "Green-E", route_type: 0), + ie(stop: "place-armnl", route: "Green-B", route_type: 0), + ie(stop: "place-armnl", route: "Green-C", route_type: 0), + ie(stop: "place-armnl", route: "Green-D", route_type: 0), + ie(stop: "place-armnl", route: "Green-E", route_type: 0) + ]) + |> put_cause(:unknown) + |> put_tagged_stop_sequences(%{ + "Green-B" => [ + [ + "place-haecl", + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl" + ] + ], + "Green-C" => [ + [ + "place-haecl", + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl" + ] + ], + "Green-D" => [ + [ + "place-haecl", + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl" + ] + ], + "Green-E" => [ + [ + "place-haecl", + "place-gover", + "place-pktrm", + "place-boyls", + "place-armnl" + ] + ] + }) + |> put_routes_at_stop([ + %{ + route_id: "Green-B", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + }, + %{ + route_id: "Green-C", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + }, + %{ + route_id: "Green-D", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + }, + %{ + route_id: "Green-E", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + } + ]) + + expected = %{ + issue: "No trains", + location: "between Arlington and Park Street", + cause: "", + routes: [%{color: :green, text: "GREEN LINE", type: :text}], + effect: :suspension, + urgent: false, + region: :outside, + remedy: "Seek alternate route" + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end - test "handles alert affecting all stops on a line", %{widget: widget} do + test "reverses endpoints for GL western branch alert", %{widget: widget} do widget = widget - |> put_home_stop(PreFare, "place-tumnl") + |> put_home_stop(PreFare, "place-symcl") |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: nil, route: "Orange") + ie(stop: "place-mfa", route: "Green-E", route_type: 0), + ie(stop: "place-lngmd", route: "Green-E", route_type: 0), + ie(stop: "place-brmnl", route: "Green-E", route_type: 0) ]) |> put_cause(:unknown) - |> put_stop_sequences([ - [ - "place-chncl", - "place-tumnl", - "place-bbsta" + |> put_tagged_stop_sequences(%{ + "Green-E" => [ + [ + "place-symcl", + "place-nuniv", + "place-mfa", + "place-lngmd", + "place-brmnl" + ] ] + }) + |> put_routes_at_stop([ + %{ + route_id: "Green-E", + active?: true, + direction_destinations: nil, + long_name: nil, + short_name: nil, + type: :subway + } + ]) + + expected = %{ + issue: "No trains", + location: "between Brigham Circle and Museum of Fine Arts", + cause: "", + routes: [%{color: :green, text: "GREEN LINE", type: :text, branches: ["E"]}], + effect: :suspension, + urgent: false, + region: :outside, + remedy: "Seek alternate route" + } + + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) + end + + test "does not reverse endpoints for GLX alert", %{ + widget: widget + } do + widget = + widget + |> put_home_stop(PreFare, "place-esomr") + |> put_effect(:suspension) + |> put_informed_entities([ + ie(stop: "place-balsq", route: "Green-E", route_type: 0), + ie(stop: "place-mgngl", route: "Green-E", route_type: 0), + ie(stop: "place-gilmn", route: "Green-E", route_type: 0) ]) + |> put_cause(:unknown) + |> put_tagged_stop_sequences(%{ + "Green-E" => [ + [ + "place-balsq", + "place-mgngl", + "place-gilmn", + "place-esomr" + ] + ] + }) |> put_routes_at_stop([ %{ - route_id: "Orange", + route_id: "Green-E", active?: true, direction_destinations: nil, long_name: nil, @@ -1076,17 +1875,17 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do ]) expected = %{ - issue: %{icon: nil, text: ["No", %{route: "orange"}, "trains"]}, - location: nil, + issue: "No trains", + location: "between Ball Square and Gilman Square", cause: "", - routes: [%{color: :orange, text: "ORANGE LINE", type: :text}], + routes: [%{color: :green, text: "GREEN LINE", type: :text, branches: ["E"]}], effect: :suspension, - urgent: true, - region: :inside, + urgent: false, + region: :outside, remedy: "Seek alternate route" } - assert expected == ReconstructedAlert.serialize(widget) + assert expected == ReconstructedAlert.serialize(widget, &fake_log/1) end end @@ -1098,8 +1897,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do widget |> put_effect(:station_closure) |> put_informed_entities([ - ie(stop: "place-alfcl", route: "Red"), - ie(stop: "place-alfcl", route: "Orange") + ie(stop: "place-alfcl", route: "Red", route_type: 1), + ie(stop: "place-alfcl", route: "Orange", route_type: 1) ]) |> put_cause(:construction) @@ -1110,42 +1909,43 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do describe "audio_sort_key/1" do setup @alert_widget_context_setup_group ++ [:setup_active_period] - test "returns [2] when alert is urgent", %{widget: widget} do + test "returns [1] when alert is urgent", %{widget: widget} do widget = widget |> put_effect(:suspension) |> put_informed_entities([ - ie(stop: "place-dwnxg", route: "Red") + ie(stop: "place-dwnxg", route: "Red", route_type: 1) ]) |> put_cause(:unknown) + |> put_is_full_screen(true) - assert [2] == WidgetInstance.audio_sort_key(widget) + assert [1] == WidgetInstance.audio_sort_key(widget) end - test "returns [2, 1] when alert is not urgent", %{widget: widget} do + test "returns [1, 2] when alert is not urgent", %{widget: widget} do widget = widget |> put_effect(:station_closure) |> put_informed_entities([ - ie(stop: "place-alfcl", route: "Red"), - ie(stop: "place-alfcl", route: "Orange") + ie(stop: "place-alfcl", route: "Red", route_type: 1), + ie(stop: "place-alfcl", route: "Orange", route_type: 1) ]) |> put_cause(:construction) - assert [2, 1] == WidgetInstance.audio_sort_key(widget) + assert [1, 2] == WidgetInstance.audio_sort_key(widget) end - test "returns [2, 2] when alert effect is :delay", %{widget: widget} do + test "returns [1, 1] when alert effect is :delay", %{widget: widget} do widget = widget |> put_effect(:delay) |> put_informed_entities([ - ie(stop: "place-alfcl", route: "Red") + ie(stop: "place-alfcl", route: "Red", route_type: 1) ]) |> put_cause(:unknown) |> put_alert_header("Test Alert") - assert [2, 2] == WidgetInstance.audio_sort_key(widget) + assert [1, 1] == WidgetInstance.audio_sort_key(widget) end end @@ -1359,7 +2159,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do ] now = ~U[2022-06-24 12:00:00Z] - station_sequences = [Stop.get_route_stop_sequence("Orange")] + tagged_station_sequences = %{"Orange" => [Stop.get_route_stop_sequence("Orange")]} + station_sequences = RoutePattern.untag_stop_sequences(tagged_station_sequences) fetch_alerts_fn = fn _ -> {:ok, alerts} end fetch_stop_name_fn = fn _ -> "Wellington" end @@ -1368,7 +2169,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do {:ok, %LocationContext{ home_stop: stop_id, - stop_sequences: station_sequences, + tagged_stop_sequences: tagged_station_sequences, upstream_stops: Stop.upstream_stop_id_set(stop_id, station_sequences), downstream_stops: Stop.downstream_stop_id_set(stop_id, station_sequences), routes: routes_at_stop, @@ -1376,15 +2177,55 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do }} end - alert_widgets = - CandidateGenerator.Widgets.ReconstructedAlert.reconstructed_alert_instances( - config, + alert_widget = + config + |> CandidateGenerator.Widgets.ReconstructedAlert.reconstructed_alert_instances( now, fetch_alerts_fn, fetch_stop_name_fn, fetch_location_context_fn ) + |> List.first() + + # Fullscreen test + expected = %{ + issue: "No trains", + location: nil, + cause: nil, + routes: [%{headsign: "Forest Hills", route_id: "Orange", svg_name: "ol-forest-hills"}], + effect: :suspension, + remedy: "Seek alternate route", + updated_at: "Friday, 5:14 am", + region: :outside, + endpoints: {"North Station", "Back Bay"}, + is_transfer_station: false, + disruption_diagram: %{ + effect: :suspension, + effect_region_slot_index_range: {6, 12}, + line: :orange, + current_station_slot_index: 2, + slots: [ + %{type: :terminal, label_id: "place-ogmnl"}, + %{label: %{full: "Malden Center", abbrev: "Malden Ctr"}, show_symbol: true}, + %{label: %{full: "Wellington", abbrev: "Wellington"}, show_symbol: true}, + %{label: %{full: "Assembly", abbrev: "Assembly"}, show_symbol: true}, + %{label: %{full: "Sullivan Square", abbrev: "Sullivan Sq"}, show_symbol: true}, + %{label: %{full: "Community College", abbrev: "Com College"}, show_symbol: true}, + %{label: %{full: "North Station", abbrev: "North Sta"}, show_symbol: true}, + %{label: %{full: "Haymarket", abbrev: "Haymarket"}, show_symbol: true}, + %{label: %{full: "State", abbrev: "State"}, show_symbol: true}, + %{label: %{full: "Downtown Crossing", abbrev: "Downt'n Xng"}, show_symbol: true}, + %{label: %{full: "Chinatown", abbrev: "Chinatown"}, show_symbol: true}, + %{label: %{full: "Tufts Medical Center", abbrev: "Tufts Med"}, show_symbol: true}, + %{label: %{full: "Back Bay", abbrev: "Back Bay"}, show_symbol: true}, + %{type: :arrow, label_id: "place-forhl"} + ] + } + } + assert expected == ReconstructedAlert.serialize(alert_widget, &fake_log/1) + + # Flexzone test expected = %{ issue: "No trains", location: "between North Station and Back Bay", @@ -1396,7 +2237,11 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "Seek alternate route" } - assert expected == ReconstructedAlert.serialize(List.first(alert_widgets)) + assert expected == + ReconstructedAlert.serialize( + %{alert_widget | is_full_screen: false}, + &fake_log/1 + ) end test "handles GL boundary shuttle at Govt Center" do @@ -1726,7 +2571,8 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do ] now = ~U[2022-06-24 12:00:00Z] - station_sequences = [Stop.get_route_stop_sequence("Green")] + tagged_station_sequences = %{"Green" => [Stop.get_route_stop_sequence("Green")]} + station_sequences = RoutePattern.untag_stop_sequences(tagged_station_sequences) fetch_alerts_fn = fn _ -> {:ok, alerts} end fetch_stop_name_fn = fn _ -> "Government Center" end @@ -1735,7 +2581,7 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do {:ok, %LocationContext{ home_stop: stop_id, - stop_sequences: station_sequences, + tagged_stop_sequences: tagged_station_sequences, upstream_stops: Stop.upstream_stop_id_set(stop_id, station_sequences), downstream_stops: Stop.downstream_stop_id_set(stop_id, station_sequences), routes: routes_at_stop, @@ -1743,15 +2589,33 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do }} end - alert_widgets = - CandidateGenerator.Widgets.ReconstructedAlert.reconstructed_alert_instances( - config, + alert_widget = + config + |> CandidateGenerator.Widgets.ReconstructedAlert.reconstructed_alert_instances( now, fetch_alerts_fn, fetch_stop_name_fn, fetch_location_context_fn ) + |> List.first() + + # Fullscreen test + expected = %{ + cause: nil, + effect: :shuttle, + issue: nil, + location: nil, + region: :boundary, + remedy: nil, + routes: [%{route_id: "Green", svg_name: "gl"}], + updated_at: "Friday, 2:24 pm", + remedy_bold: + "Green Line is replaced by shuttle buses between Government Center and Union Square due to a structural issue with the Government Center Garage. Shuttle buses are not servicing Haymarket Station." + } + + assert expected == ReconstructedAlert.serialize(alert_widget, &fake_log/1) + # Flexzone test expected = %{ issue: "No North Station & North trains", location: "", @@ -1763,7 +2627,11 @@ defmodule Screens.V2.WidgetInstance.ReconstructedAlertTest do remedy: "Use shuttle bus" } - assert expected == ReconstructedAlert.serialize(List.first(alert_widgets)) + assert expected == + ReconstructedAlert.serialize( + %{alert_widget | is_full_screen: false}, + &fake_log/1 + ) end end end diff --git a/test/screens/v2/widget_instance/subway_status_test.exs b/test/screens/v2/widget_instance/subway_status_test.exs index 13b9e444d..6bd35b8a7 100644 --- a/test/screens/v2/widget_instance/subway_status_test.exs +++ b/test/screens/v2/widget_instance/subway_status_test.exs @@ -1572,9 +1572,9 @@ defmodule Screens.V2.WidgetInstance.SubwayStatusTest do end describe "audio_sort_key/1" do - test "returns [1]" do + test "returns [2]" do instance = %SubwayStatus{} - assert [1] == WidgetInstance.audio_sort_key(instance) + assert [2] == WidgetInstance.audio_sort_key(instance) end end diff --git a/test/support/disruption_diagram_localized_alert.ex b/test/support/disruption_diagram_localized_alert.ex new file mode 100644 index 000000000..b57237f2d --- /dev/null +++ b/test/support/disruption_diagram_localized_alert.ex @@ -0,0 +1,183 @@ +defmodule Screens.TestSupport.DisruptionDiagramLocalizedAlert do + @moduledoc """ + Provides a function that generates localized alerts intended for + use with disruption diagrams. + + Only the struct fields required by disruption diagrams are populated, + so this might not work for testing other code related to localized alerts. + """ + + alias Screens.Alerts.Alert + alias Screens.LocationContext + alias Screens.Stops.Stop + + @doc """ + Creates a localized alert with the given effect, located at the given home station. + + When creating a station closure alert, `informed_stops` should be a list of stop IDs. + + When creating a shuttle or suspension, `informed_stops` should be a tuple of `{first_stop_id, last_stop_id}`. + Keep in mind that stop order will be based on sequences for direction_id=0. + For example, a shuttle from DTX to Back Bay must be entered as + `{"place-dwnxg", "place-bbsta"}`, not `{"place-bbsta", "place-dwnxg"}`. + + Options: + - :informed_routes + - If `:per_stop`, the informed route(s) for each stop will be all subway routes that serve it. + When a GL trunk stop is disrupted, it will always get informed entities for all routes that serve it, + even if the alert later goes down one particular branch. + - If `:overall`, the informed route(s) will be whichever fully contain all informed stops. + For alerts that inform any GL branch stops, this means the only informed route will be that branch. + This is the default AlertsUI behavior. + - Defaults to `:overall`. + """ + def make_localized_alert(effect, line, home_station_id, informed_stops, opts \\ []) + + def make_localized_alert(:station_closure, line, home_station_id, stop_ids, opts) + when is_list(stop_ids) do + alert = %Alert{ + effect: :station_closure, + informed_entities: + ies(line, stop_ids, Keyword.get(opts, :informed_routes, :overall), home_station_id) + } + + %{alert: alert, location_context: make_location_context(home_station_id)} + end + + def make_localized_alert(continuous, line, home_station_id, {_first, _last} = stop_range, opts) + when continuous in [:shuttle, :suspension] do + alert = %Alert{ + effect: continuous, + informed_entities: + ies( + line, + stop_range_to_list(stop_range), + Keyword.get(opts, :informed_routes, :overall), + home_station_id + ) + } + + %{alert: alert, location_context: make_location_context(home_station_id)} + end + + defp make_location_context(home_station_id) do + %LocationContext{ + home_stop: home_station_id, + tagged_stop_sequences: tagged_stop_sequences_through_station(home_station_id) + } + end + + defp ies(:green, stop_ids, :per_stop, _home_stop) do + for stop_id <- stop_ids, + "Green" <> _ = route_id <- subway_routes_at_station(stop_id), + do: %{route: route_id, stop: stop_id} + end + + defp ies(:green, stop_ids, :overall, home_stop) do + route_ids = + [home_stop | stop_ids] + |> MapSet.new() + |> routes_containing_all() + |> Enum.filter(&match?("Green" <> _, &1)) + + result = + for stop_id <- stop_ids, + route_id <- route_ids, + do: %{route: route_id, stop: stop_id} + + if result == [] do + raise "No stop sequence contains all informed stops + home stop" + else + result + end + end + + defp ies(line, stop_ids, _, _) when line in [:blue, :orange, :red] do + route_id = + line + |> Atom.to_string() + |> String.capitalize() + + for stop_id <- stop_ids, do: %{route: route_id, stop: stop_id} + end + + defp stop_range_to_list({first_station_id, last_station_id}) do + endpoints_set = MapSet.new([first_station_id, last_station_id]) + + Stop.get_all_routes_stop_sequence() + |> Enum.find_value(fn + {_route_id, labeled_sequences} -> + Enum.find_value(labeled_sequences, fn labeled_sequence -> + stop_sequence = Enum.map(labeled_sequence, &elem(&1, 0)) + if MapSet.subset?(endpoints_set, MapSet.new(stop_sequence)), do: stop_sequence + end) + end) + |> case do + nil -> + raise "No stop sequence contains both of the two given stations: {#{first_station_id}, #{last_station_id}}" + + sequence -> + index_of_first = Enum.find_index(sequence, &(&1 == first_station_id)) + index_of_last = Enum.find_index(sequence, &(&1 == last_station_id)) + + Enum.slice(sequence, index_of_first..index_of_last//1) + end + end + + # Returns IDs of the subway/light rail route(s) that serve the given station, + # using our hardcoded stop sequences rather than API calls. + defp subway_routes_at_station(parent_station_id) do + Stop.get_all_routes_stop_sequence() + |> Enum.filter(fn + # Green isn't a real route ID, ignore it. + {"Green", _} -> + false + + {_route_id, labeled_sequences} -> + stop_sequences = + Enum.map(labeled_sequences, fn labeled_sequence -> + Enum.map(labeled_sequence, &elem(&1, 0)) + end) + + Enum.any?(stop_sequences, &(parent_station_id in &1)) + end) + |> Enum.map(fn {route_id, _stop_sequences} -> route_id end) + end + + # Returns a %{route => stop_sequences} map for all sequences that that contain the given subway/light rail station. + defp tagged_stop_sequences_through_station(parent_station_id) do + Stop.get_all_routes_stop_sequence() + |> Enum.flat_map(fn + # Green isn't a real route ID, ignore it. + {"Green", _} -> + [] + + {route_id, labeled_sequences} -> + matching_stop_sequences = + Enum.flat_map(labeled_sequences, fn labeled_sequence -> + stop_sequence = Enum.map(labeled_sequence, &elem(&1, 0)) + if parent_station_id in stop_sequence, do: [stop_sequence], else: [] + end) + + if matching_stop_sequences != [], do: [{route_id, matching_stop_sequences}], else: [] + end) + |> Map.new() + end + + # Returns IDs of the route(s) whose stop sequence(s) contain all of the given stops. + defp routes_containing_all(parent_station_ids) do + Stop.get_all_routes_stop_sequence() + |> Enum.filter(fn + # Green isn't a real route ID, ignore it. + {"Green", _} -> + false + + {_route_id, labeled_sequences} -> + Enum.any?(labeled_sequences, fn labeled_sequence -> + stops = MapSet.new(labeled_sequence, &elem(&1, 0)) + MapSet.subset?(parent_station_ids, stops) + end) + end) + |> Enum.map(fn {route_id, _} -> route_id end) + end +end diff --git a/test/support/parent_station_id_sigil.ex b/test/support/parent_station_id_sigil.ex new file mode 100644 index 000000000..8a98de5f3 --- /dev/null +++ b/test/support/parent_station_id_sigil.ex @@ -0,0 +1,29 @@ +defmodule Screens.TestSupport.ParentStationIdSigil do + @doc ~S""" + Makes a single `"place-#{term}"` string, or a list of them if term contains 2+ words. + Can be used in patterns and guards. + + ``` + iex> import Screens.TestSupport.ParentStationIdSigil + + iex> ~P"haecl" + "place-haecl" + + iex> ~P[alfcl davis portr] + ["place-alfcl", "place-davis", "place-portr"] + + # The use of "" vs [] doesn't make a difference, they just help to indicate the type. + iex> ~P[haecl] + "place-haecl" + + iex> ~P"alfcl davis portr" + ["place-alfcl", "place-davis", "place-portr"] + ``` + """ + defmacro sigil_P({:<<>>, _meta, [term]}, _modifiers) when is_binary(term) do + case String.split(term) do + [place_id] -> "place-#{place_id}" + place_ids -> :lists.map(&"place-#{&1}", place_ids) + end + end +end diff --git a/test/support/subway_tagged_stop_sequences.ex b/test/support/subway_tagged_stop_sequences.ex new file mode 100644 index 000000000..919bf2fbb --- /dev/null +++ b/test/support/subway_tagged_stop_sequences.ex @@ -0,0 +1,71 @@ +defmodule Screens.TestSupport.SubwayTaggedStopSequences do + @moduledoc """ + Functions providing tagged stop sequences for building subway-related test data. + """ + + import Screens.TestSupport.ParentStationIdSigil + + @spec blue() :: %{Route.id() => [[Stop.id()]]} + def blue do + %{"Blue" => [~P[wondl rbmnl bmmnl sdmnl orhte wimnl aport mvbcl aqucl state gover bomnl]]} + end + + @spec orange() :: %{Route.id() => [[Stop.id()]]} + def orange do + %{ + "Orange" => [ + ~P[ogmnl mlmnl welln astao sull ccmnl north haecl state dwnxg chncl tumnl bbsta masta rugg rcmnl jaksn sbmnl grnst forhl] + ] + } + end + + @spec red(list(atom())) :: %{Route.id() => [[Stop.id()]]} + def red(branches \\ ~w[ashmont braintree]a) do + [ + :ashmont in branches and ashmont_seq(), + :braintree in branches and braintree_seq() + ] + |> Enum.filter(& &1) + |> then(&%{"Red" => &1}) + end + + @spec green(list(atom())) :: %{Route.id() => [[Stop.id()]]} + def green(branches \\ ~w[b c d e]a) do + [ + :b in branches and {"Green-B", [b_seq()]}, + :c in branches and {"Green-C", [c_seq()]}, + :d in branches and {"Green-D", [d_seq()]}, + :e in branches and {"Green-E", [e_seq()]} + ] + |> Enum.filter(& &1) + |> Map.new() + end + + defp ashmont_seq do + red_trunk_seq() ++ ~P[shmnl fldcr smmnl asmnl] + end + + defp braintree_seq do + red_trunk_seq() ++ ~P[nqncy wlsta qnctr qamnl brntn] + end + + defp red_trunk_seq do + ~P[alfcl davis portr harsq cntsq knncl chmnl pktrm dwnxg sstat brdwy andrw jfk] + end + + defp b_seq do + ~P[gover pktrm boyls armnl coecl hymnl kencl bland buest bucen amory babck brico harvd grigg alsgr wrnst wascm sthld chswk chill sougr lake] + end + + defp c_seq do + ~P[gover pktrm boyls armnl coecl hymnl kencl smary hwsst kntst stpul cool sumav bndhl fbkst bcnwa tapst denrd engav clmnl] + end + + defp d_seq do + ~P[unsqu lech spmnl north haecl gover pktrm boyls armnl coecl hymnl kencl fenwy longw bvmnl brkhl bcnfd rsmnl chhil newto newtn eliot waban woodl river] + end + + defp e_seq do + ~P[mdftf balsq mgngl gilmn esomr lech spmnl north haecl gover pktrm boyls armnl coecl prmnl symcl nuniv mfa lngmd brmnl fenwd mispk rvrwy bckhl hsmnl] + end +end