prop/test/microwaveprop_web/live/map_live_test.exs
Graham McIntire c514626a62
test: coverage round 3 (83.96% → 84.39%) + 10 new property tests
64 unit tests + 10 property tests across three parallel agents.

- ContactLive.Show round 3 (72% → ~80%): enrichment failure
  surfaces (terrain/iemre/weather :failed, hrrr :unavailable NARR
  fallback), radar-only mechanism, load_solar + enqueue_missing_solar
  paths, blocked_message nil obs_dist, band_summary for 144M/1296M/
  2304M/47G/75G, admin edit no-op, mode changes, fetch_queue_counts
  with seeded Oban jobs. Properties: propagation_mechanism summary is
  always a binary, obs_sort_key totality over arbitrary field keys.

- Weather clients: iem_client transport timeouts + CSV edge cases,
  rtma_client idx url property, swpc_client HTTP 503/404 + empty-
  array parses + JSON/CSV totality properties, ncei_metar_client
  parse edge cases, snmp_client OID-roundtrip property + parse
  edge cases.

- Mid-coverage LiveViews: UserProfileLive avatar/render branches,
  EmeLive invalid-grid/unknown-callsign + form-validation property,
  PathLive zero-distance + 3000km + height/power round-trip property,
  BeaconLive numeric bearing + non-admin approve denied, MapLive
  select_band/toggle_radar/toggle_grid events + band-URL property.

Fix: propagation_mechanism property test uses System.unique_integer
for lat + valid_time nonces instead of StreamData-shrinkable jitter;
the shrink toward 0 was tripping the hrrr_profiles unique index.

205 → 215 properties, 2743 → 2812 tests, 0 failures.
2026-04-24 10:15:37 -05:00

717 lines
25 KiB
Elixir

defmodule MicrowavepropWeb.MapLiveTest do
use MicrowavepropWeb.ConnCase, async: false
use ExUnitProperties
import Phoenix.ConnTest, only: [get: 2]
import Phoenix.LiveViewTest
alias Microwaveprop.Propagation
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ScoreCache
alias Phoenix.LiveView.AsyncResult
alias Phoenix.LiveView.Utils
describe "mount" do
test "renders full-page map", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ ~s(id="propagation-map")
assert html =~ ~s(phx-hook="PropagationMap")
end
test "renders band dropdown with all bands", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "10 GHz"
assert html =~ "24 GHz"
assert html =~ "47 GHz"
assert html =~ "75 GHz"
assert html =~ "241 GHz"
end
test "defaults to 10 GHz selected", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ ~s(class="active")
end
test "includes data-scores attribute", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "data-scores"
end
test "renders grid toggle", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "Grid squares"
assert html =~ ~s(phx-click="toggle_grid")
end
test "renders scoring algorithm link", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "Scoring Algorithm"
assert html =~ ~s(/algo)
end
test "renders submit QSO link", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "Submit a Contact"
assert html =~ ~s(/submit)
end
end
describe "async initial scores" do
setup do
ScoreCache.clear()
on_exit(fn -> ScoreCache.clear() end)
:ok
end
test "disconnected HTTP render returns 200 with a data-scores payload", %{conn: conn} do
valid_time =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 42, factors: nil}],
valid_time
)
conn = get(conn, ~p"/map")
assert conn.status == 200
body = Phoenix.ConnTest.html_response(conn, 200)
# data-scores is baked into the static HTTP render so first paint
# ships real data even without a websocket connection.
assert body =~ ~s(data-scores=)
refute body =~ ~s(data-scores="[]")
end
test "connected mount resolves @initial_scores to an :ok AsyncResult", %{conn: conn} do
valid_time =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 77, factors: nil}],
valid_time
)
{:ok, lv, _html} = live(conn, ~p"/map")
# The websocket mount kicks off start_async(:initial_scores, ...) and
# pushes `update_scores` once the fetch resolves.
assert_push_event(lv, "update_scores", %{scores: scores})
assert is_list(scores)
# Force a render so the AsyncResult assignment is flushed, then
# inspect the socket's assigns directly.
_ = render(lv)
async_result = :sys.get_state(lv.pid).socket.assigns.initial_scores
assert %AsyncResult{ok?: true} = async_result
end
end
describe "CONUS-only banner" do
@banner_copy "This data set currently covers the continental United States only."
test "is hidden when no CF lat/lon in session", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
refute html =~ ~s(id="conus-banner")
refute html =~ @banner_copy
end
test "is hidden when CF lat/lon is inside CONUS (DFW)", %{conn: conn} do
conn = Phoenix.ConnTest.init_test_session(conn, %{cf_lat: 32.9, cf_lon: -97.0})
{:ok, _lv, html} = live(conn, ~p"/map")
refute html =~ ~s(id="conus-banner")
refute html =~ @banner_copy
end
test "is visible when CF lat/lon is outside CONUS (London)", %{conn: conn} do
conn = Phoenix.ConnTest.init_test_session(conn, %{cf_lat: 51.5, cf_lon: -0.1})
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ ~s(id="conus-banner")
assert html =~ @banner_copy
end
end
describe "select_band" do
test "updates selected band on event", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
html = render_click(lv, "select_band", %{"value" => 24_000})
assert html =~ "24 GHz"
end
test "patches the URL with the newly-selected band", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "select_band", %{"value" => 47_000})
url = assert_patch(lv)
assert url =~ "band=47000"
end
test "select_band pushes update_scores to the client for the new band", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
# Drain any async initial push_event before asserting on the
# band-switch one.
_ = render(lv)
render_click(lv, "select_band", %{"value" => 24_000})
assert_push_event(lv, "update_scores", %{scores: _scores})
end
end
describe "property: select_band patches the URL for every configured band" do
property "each configured band patches to a URL containing band=<mhz>" do
bands = Enum.map(BandConfig.band_options(), fn {_l, v} -> String.to_integer(v) end)
check all(band <- StreamData.member_of(bands), max_runs: 8) do
conn = Phoenix.ConnTest.build_conn()
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "select_band", %{"value" => band})
url = assert_patch(lv)
assert url =~ "band=#{band}",
"expected /map patch URL to contain band=#{band}, got #{inspect(url)}"
end
end
end
describe "compute_viewshed" do
test "accepts compute_viewshed event without crashing", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
# Event should be accepted; async computation may fail (no SRTM in test)
# but the LiveView should not crash
render_click(lv, "compute_viewshed", %{"lat" => 32.9, "lon" => -97.0})
# If we get here without crash, the event handler exists
assert render(lv) =~ "propagation-map"
end
end
describe "toggle_grid" do
test "toggles grid visibility", %{conn: conn} do
{:ok, lv, html} = live(conn, ~p"/map")
refute html =~ "checked"
html = render_click(lv, "toggle_grid")
assert html =~ "checked"
html = render_click(lv, "toggle_grid")
refute html =~ "checked"
end
end
describe "toggle_radar" do
test "flips the radar layer visibility assign", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
refute :sys.get_state(lv.pid).socket.assigns.radar_visible
render_click(lv, "toggle_radar")
assert :sys.get_state(lv.pid).socket.assigns.radar_visible
render_click(lv, "toggle_radar")
refute :sys.get_state(lv.pid).socket.assigns.radar_visible
end
test "pushes toggle_radar to the client with the new visibility", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "toggle_radar")
assert_push_event(lv, "toggle_radar", %{visible: true})
render_click(lv, "toggle_radar")
assert_push_event(lv, "toggle_radar", %{visible: false})
end
end
describe "toggle_grid event payload" do
test "pushes toggle_grid to the client with the new visibility", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "toggle_grid")
assert_push_event(lv, "toggle_grid", %{visible: true})
render_click(lv, "toggle_grid")
assert_push_event(lv, "toggle_grid", %{visible: false})
end
end
describe "select_time / set_selected_time" do
test "set_selected_time accepts an iso8601 string and updates the assign",
%{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
now_iso = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
render_hook(lv, "set_selected_time", %{"time" => now_iso})
# The handler accepts the event without crashing. Whether the new
# time is actually adopted depends on valid_times, which is empty
# in test (no ScoresFiles); that's fine — we're testing the
# handler path exists and doesn't raise on malformed/new input.
assert Process.alive?(lv.pid)
end
test "select_time event is accepted and doesn't crash the LV",
%{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "select_time", %{"time" => "2026-04-21T22:00:00Z"})
assert Process.alive?(lv.pid)
end
test "select_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "select_time", %{"time" => "definitely-not-a-time"})
assert Process.alive?(lv.pid)
end
test "set_selected_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_hook(lv, "set_selected_time", %{"time" => "definitely-not-a-time"})
assert Process.alive?(lv.pid)
end
test "select_time with valid iso updates selected_time assign", %{conn: conn} do
valid_time =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 55, factors: nil}],
valid_time
)
{:ok, lv, _html} = live(conn, ~p"/map")
iso = DateTime.to_iso8601(valid_time)
render_click(lv, "select_time", %{"time" => iso})
assert :sys.get_state(lv.pid).socket.assigns.selected_time == valid_time
end
end
describe "point_detail" do
test "handler pushes a `point_detail` event to the client without crashing",
%{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
# point_detail does no server-rendered DOM update — it calls
# push_event/3 with the detail payload and a JS hook consumes it.
# The assertion is that the handler returns cleanly even in the
# "no HRRR profile" path (dev/test has empty .prop files).
render_click(lv, "point_detail", %{"lat" => 32.9, "lon" => -97.04})
assert Process.alive?(lv.pid)
assert render(lv) =~ "propagation-map"
end
end
describe "map_bounds" do
test "stores the viewport and debounces a forecast preload", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_hook(lv, "map_bounds", %{
"south" => 30.0,
"north" => 35.0,
"west" => -100.0,
"east" => -95.0
})
bounds = :sys.get_state(lv.pid).socket.assigns.bounds
assert bounds["south"] == 30.0
assert bounds["north"] == 35.0
assert bounds["west"] == -100.0
assert bounds["east"] == -95.0
end
end
describe "retry_initial_scores" do
test "event is accepted without crashing the LV", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
render_click(lv, "retry_initial_scores")
assert Process.alive?(lv.pid)
end
end
describe "daily outlook strip" do
test "no longer renders on the main map", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
refute html =~ "daily-outlook"
refute html =~ "7-day outlook"
refute html =~ "Extended outlook"
end
end
describe "data timestamp indicator" do
setup do
# Earlier tests in the suite may have written score files into the
# shared `:propagation_scores_dir` and warmed the 5 s
# `list_valid_times` cache. Reset both so this test starts from the
# "no data" state its assertion describes.
scores_dir = Application.fetch_env!(:microwaveprop, :propagation_scores_dir)
_ = File.rm_rf(scores_dir)
:ets.match_delete(
:microwaveprop_cache,
{{Microwaveprop.Propagation.ScoresFile, :list_valid_times, :_, :_}, :_, :_}
)
:ok
end
test "renders a 'Data from …' line on both mobile and desktop sidebars", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ ~s(id="data-timestamp-mobile")
assert html =~ ~s(id="data-timestamp-desktop")
# Clean test DB has no scores; the indicator falls back to "No data".
assert html =~ "No data"
end
end
describe "URL params" do
test "band URL param selects the requested band on mount", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map?band=24000")
assert html =~ ~s(data-selected-band="24000")
end
test "lat/lon/zoom URL params override the default map center/zoom", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map?lat=40.0&lon=-100.0&zoom=5")
assert html =~ ~s(data-center-lat="40.0")
assert html =~ ~s(data-center-lon="-100.0")
assert html =~ ~s(data-zoom="5")
end
test "invalid band falls back to the default", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map?band=notanumber")
assert html =~ ~s(data-selected-band="10000")
end
test "unknown band MHz falls back to the default", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map?band=99999")
assert html =~ ~s(data-selected-band="10000")
end
test "malformed lat/lon/zoom are ignored", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map?lat=banana&lon=apple&zoom=over9000")
assert html =~ ~s(data-zoom="7")
refute html =~ ~s(data-center-lat="banana")
end
test "out-of-range lat/lon are ignored", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map?lat=500&lon=-999")
refute html =~ ~s(data-center-lat="500)
end
test "out-of-range zoom falls back to default", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map?zoom=42")
assert html =~ ~s(data-zoom="7")
end
test "unparseable time is ignored", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map?t=not-a-date")
# Page renders normally without crashing.
assert render(lv) =~ "data-selected-time"
end
test "ISO8601 time outside the current forecast window falls back", %{conn: conn} do
# Year 2000 is well before any valid HRRR data; page should still
# mount and fall back to the normal 'now' cursor rather than try
# to fetch scores for an empty time.
{:ok, _lv, _html} = live(conn, ~p"/map?t=2000-01-01T00:00:00Z")
end
test "every param garbage at once still mounts cleanly", %{conn: conn} do
{:ok, _lv, html} =
live(conn, ~p"/map?band=xyz&t=nope&lat=abc&lon=def&zoom=foo")
assert html =~ ~s(data-selected-band="10000")
assert html =~ ~s(data-zoom="7")
end
end
describe "cursor_for_now/2" do
alias MicrowavepropWeb.MapLive
test "returns nil for an empty list" do
assert MapLive.cursor_for_now([], DateTime.utc_now()) == nil
end
test "picks the latest time at or before now — never future" do
# Valid times at 18:00, 19:00, 20:00 UTC. Wall clock 19:35.
# Nearest-by-abs would round up to 20:00 (25 min future);
# floor picks 19:00 (35 min past).
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
now = ~U[2026-04-18 19:35:00Z]
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z]
end
test "matches exact hour when now is on the hour" do
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]]
assert MapLive.cursor_for_now(times, ~U[2026-04-18 19:00:00Z]) == ~U[2026-04-18 19:00:00Z]
end
test "falls back to the earliest time when every slot is in the future" do
times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 21:00:00Z]]
now = ~U[2026-04-18 19:00:00Z]
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 20:00:00Z]
end
test "ignores list order" do
times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]]
now = ~U[2026-04-18 19:35:00Z]
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z]
end
end
describe "tracking_now?/2" do
alias MicrowavepropWeb.MapLive
test "true when selected time equals the latest at-or-before-now slot" do
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
now = ~U[2026-04-18 19:35:00Z]
# closest_to_now at 19:35 with these slots is 19:00.
assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], times, now) == true
end
test "false when selected time is an earlier historical slot" do
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
now = ~U[2026-04-18 19:35:00Z]
assert MapLive.tracking_now?(~U[2026-04-18 18:00:00Z], times, now) == false
end
test "false when selected time is a future forecast slot" do
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
now = ~U[2026-04-18 19:35:00Z]
assert MapLive.tracking_now?(~U[2026-04-18 20:00:00Z], times, now) == false
end
test "false when valid_times is empty" do
assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], [], ~U[2026-04-18 19:00:00Z]) == false
end
test "false when selected_time is nil" do
times = [~U[2026-04-18 19:00:00Z]]
assert MapLive.tracking_now?(nil, times, ~U[2026-04-18 19:00:00Z]) == false
end
end
describe "advance_now_cursor handler" do
setup do
ScoreCache.clear()
on_exit(fn -> ScoreCache.clear() end)
:ok
end
test "advances selected_time to the newly-past hour when tracking now", %{conn: conn} do
# Seed two adjacent hours so there's somewhere to advance to. The
# later of the two is the one we want the cursor to snap to once
# the clock ticks past it.
previous_hour =
DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour)
current_hour = DateTime.add(previous_hour, 3600, :second)
for t <- [previous_hour, current_hour] do
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}],
t
)
end
{:ok, lv, _html} = live(conn, ~p"/map")
# Force the LV onto the previous hour and mark it as tracking_now?
# so the tick handler has a clear advance opportunity.
:sys.replace_state(lv.pid, fn state ->
socket =
state.socket
|> Utils.assign(:selected_time, previous_hour)
|> Utils.assign(:tracking_now?, true)
%{state | socket: socket}
end)
send(lv.pid, :advance_now_cursor)
_ = render(lv)
assigns = :sys.get_state(lv.pid).socket.assigns
assert assigns.selected_time == current_hour
end
test "leaves selected_time alone when the user is on a specific hour", %{conn: conn} do
previous_hour =
DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour)
current_hour = DateTime.add(previous_hour, 3600, :second)
for t <- [previous_hour, current_hour] do
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}],
t
)
end
{:ok, lv, _html} = live(conn, ~p"/map")
:sys.replace_state(lv.pid, fn state ->
socket =
state.socket
|> Utils.assign(:selected_time, previous_hour)
|> Utils.assign(:tracking_now?, false)
%{state | socket: socket}
end)
send(lv.pid, :advance_now_cursor)
_ = render(lv)
assigns = :sys.get_state(lv.pid).socket.assigns
assert assigns.selected_time == previous_hour
end
end
describe "format_data_timestamp/1" do
alias MicrowavepropWeb.MapLive
test "returns 'No data' for nil" do
assert MapLive.format_data_timestamp(nil) == "No data"
end
test "renders the valid_time and a 'now' suffix when within ±30 minutes" do
now = DateTime.utc_now()
label = MapLive.format_data_timestamp(now)
assert label =~ "UTC"
assert label =~ "now"
end
test "renders a '+Nh' suffix when the valid_time is in the future" do
future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second)
assert MapLive.format_data_timestamp(future) =~ "+3h"
end
test "renders a 'Nh ago' suffix when the valid_time is in the past" do
past = DateTime.add(DateTime.utc_now(), -4 * 3600, :second)
assert MapLive.format_data_timestamp(past) =~ "4h ago"
end
end
describe "propagation_updated handler" do
setup do
ScoreCache.clear()
on_exit(fn ->
ScoreCache.clear()
end)
:ok
end
test "pushes freshly-written scores when the cache still holds stale ones", %{conn: conn} do
# Use a valid_time close enough to now that available_valid_times
# keeps it in the timeline.
valid_time = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
# Seed the on-disk store and warm the cache with the same content
# so the map has a baseline to render on mount.
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 40, factors: nil}],
valid_time
)
Propagation.warm_cache_and_broadcast(10_000, valid_time)
ScoreCache.sync()
{:ok, lv, _html} = live(conn, ~p"/map")
# The connected mount kicks off an async initial-score fetch that
# emits its own `update_scores` push_event. Drain it here so the
# assertion below observes the propagation_updated delivery only.
assert_push_event(lv, "update_scores", %{scores: _initial_scores})
# Simulate a new forecast-hour compute: rewrite the .prop file on
# disk to the updated score, but do *not* touch the cache. This
# reproduces the race where the cache_refresh message has not yet
# been applied by the time MapLive handles propagation_updated.
Propagation.replace_scores(
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 90, factors: nil}],
valid_time
)
# Deliver the propagation_updated PubSub message directly.
send(lv.pid, {:propagation_updated, [valid_time]})
# The map must emit the new score (90), not the stale cached one (40).
# Wire format is packed: each point is `[lat, lon, score]`.
assert_push_event(lv, "update_scores", %{scores: scores})
assert Enum.any?(scores, fn [_, _, score] -> score == 90 end)
refute Enum.any?(scores, fn [_, _, score] -> score == 40 end)
end
end
describe "pipeline status chip" do
test "renders the aggregated pipeline status chip on mount", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
# Both the mobile and desktop sidebars carry their own chip.
assert html =~ ~s(id="pipeline-status-mobile")
assert html =~ ~s(id="pipeline-status-desktop")
# With a clean test DB no pipeline workers have ever run.
assert html =~ ~s(data-pipeline-state="unknown")
end
test "shows the current forecast hour while PropagationGridWorker runs", %{conn: conn} do
# Put a grid-worker row in executing state so PipelineStatus.current/0
# reports :running. Bypassing Oban's normal insert keeps the SQL
# sandbox from running the worker inline.
now = DateTime.utc_now()
Microwaveprop.Repo.insert!(
struct!(Oban.Job, %{
state: "executing",
queue: "propagation",
worker: "Microwaveprop.Workers.PropagationGridWorker",
args: %{},
attempt: 1,
max_attempts: 3,
inserted_at: now,
scheduled_at: now,
attempted_at: now
})
)
{:ok, lv, html} = live(conn, ~p"/map")
# Before any progress message arrives the chip uses the base label.
assert html =~ "Updating propagation"
assert html =~ ~s(data-pipeline-state="running")
future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second)
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"propagation:pipeline",
{:propagation_pipeline_progress, %{forecast_hour: 5, valid_time: future}}
)
html = render(lv)
assert html =~ "through +3h"
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"propagation:pipeline",
{:propagation_pipeline_progress, %{forecast_hour: 2, valid_time: DateTime.utc_now()}}
)
html = render(lv)
assert html =~ "through now"
end
end
end