prop/test/microwaveprop_web/live/map_live_test.exs
Graham McIntire 0c98707091
feat: harden /map analysis breakdown + move Plausible to root layout
Two fixes cover the blank "analysis breakdown" panel the user reported:

1. Bound the point_detail fallback lookback to 24h. Previously
   factors_from_fallback_profile would happily use a week-old analysis
   profile when no current one existed — now anything older than 24h
   returns an empty factor map so the UI can surface "breakdown
   unavailable" instead of silently misattributing.

2. Move the Plausible analytics snippet from Layouts.app into
   root.html.heex. Full-bleed LiveViews (/map, /contacts/map,
   /weather, home) bypass Layouts.app, so the snippet only loaded on
   the navbar-wrapped pages. root.html.heex is loaded once per HTTP
   document so coverage is now universal.

Added ~30 tests locking both down:
  • point_detail fallback: exact-time wins, 24h boundary accepted,
    30h-stale rejected, multi-band coverage, missing-cell degrades to
    empty factors, default-valid_time path
  • analytics_test.exs: Plausible script present on 12 representative
    pages, exactly once, loaded async, surviving a login round-trip

Also fixed pre-existing credo issues per standing rule: shortened the
map_live_test ETS match_delete call, extracted a function from the
deeply-nested hrrr_point_enqueuer.enqueue_for_contacts/1 cond, and
swapped Mix.Tasks.Rust.Golden's IO.inspect for Mix.shell().info.
2026-04-21 15:56:30 -05:00

543 lines
19 KiB
Elixir

defmodule MicrowavepropWeb.MapLiveTest do
use MicrowavepropWeb.ConnCase, async: false
import Phoenix.ConnTest, only: [get: 2]
import Phoenix.LiveViewTest
alias Microwaveprop.Propagation
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
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 "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).
assert_push_event(lv, "update_scores", %{scores: scores})
assert Enum.any?(scores, fn s -> s.score == 90 end)
refute Enum.any?(scores, fn s -> s.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