prop/test/microwaveprop_web/live/map_live_test.exs
Graham McIntire 6a90d153ca
perf(map): /scores/cells binary endpoint, client-side fetch + band/time prefetch
Apply the same overlay refactor we did for /weather to the propagation
map. Score data used to flow as JSON via LiveView push_event
(update_scores + the 18-hour preload_forecast batch); switching bands
or scrubbing time meant a synchronous server roundtrip + ~MB JSON push
per change.

Now:

1. New /scores/cells endpoint returns a "PSCR" binary cell-pack —
   columnar f32 lats/lons + u8 scores, ~3-4× smaller than JSON and
   parsed via DataView + typed-array views with no JSON.parse.

2. JS hook owns score fetching. mount/moveend/band/time changes
   trigger HTTP fetches against /scores/cells. After the initial
   paint, every other forecast hour for the current band is fetched
   in parallel in the background — timeline scrubs after the prefetch
   finishes are pure cache hits with no network at all.

3. LiveView no longer pushes scores. select_band emits update_band_info
   with band_mhz so the client knows what to refetch; select_time and
   set_selected_time only update server-side state (URL, point detail
   bbox). map_bounds, propagation_updated, and advance_now_cursor all
   skip the score push — pack_scores, schedule_bounds_update,
   schedule_preload_forecast, the :flush_bounds and :preload_forecast
   handle_info clauses, and the start_async :initial_scores lifecycle
   (incl. retry_initial_scores event) are all deleted.

Net result: instant band toggles after the first paint, no LiveView
WebSocket payloads larger than the timeline metadata, and ~370 fewer
lines in MapLive.
2026-04-29 14:27:20 -05:00

653 lines
22 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.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-selected-band and data-selected-time attributes", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/map")
assert html =~ "data-selected-band"
assert html =~ "data-selected-time"
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 "score data attributes" do
setup do
ScoreCache.clear()
on_exit(fn -> ScoreCache.clear() end)
:ok
end
test "static HTTP render advertises selected band/time so the client can fetch /scores/cells",
%{conn: conn} do
conn = get(conn, ~p"/map")
assert conn.status == 200
body = Phoenix.ConnTest.html_response(conn, 200)
# data-* attributes seed the JS hook with the band + valid_time
# to fetch via /scores/cells. No score JSON is baked into the
# initial HTML anymore.
assert body =~ ~s(data-selected-band=)
assert body =~ ~s(data-selected-time=)
refute body =~ ~s(data-scores=)
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_band_info with the new band id so the client refetches", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/map")
_ = render(lv)
render_click(lv, "select_band", %{"value" => 24_000})
assert_push_event(lv, "update_band_info", %{band_info: _info, band_mhz: 24_000})
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 "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 "refreshes valid_times timeline so the client refetches on the new hour", %{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: 40, factors: nil}],
valid_time
)
Propagation.warm_cache_and_broadcast(10_000, valid_time)
ScoreCache.sync()
{:ok, lv, _html} = live(conn, ~p"/map")
# Score updates flow client-side via /scores/cells now; the
# LiveView only republishes the timeline so the client knows
# which valid_times are available. Verify the timeline push
# carries the seeded valid_time.
send(lv.pid, {:propagation_updated, [valid_time]})
assert_push_event(lv, "update_timeline", %{times: times})
assert Enum.any?(times, fn t -> t.time == DateTime.to_iso8601(valid_time) 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