prop/test/microwaveprop_web/live/weather_map_live_test.exs
Graham McIntire 0d00d1777c
fix: 9 bugs across tests and production code
- Fix Module.safe_concat -> Module.concat in tests with dynamic process names
  (safe_concat calls binary_to_existing_atom, but test names are newly generated)
- Fix PSKR AggregatorTest sandbox ownership by switching to async: false
- Fix MapLiveTest assert_patch: regex unsupported in LiveView 1.2, use string match
- Fix WeatherMapLiveTest toggle_grid: assert on checked attribute, not data-grid
- Fix BeaconLive.Index leaking unapproved beacons: wire data_provider to approved_beacons_query
- Fix ContactLive.Index leaking private contacts: wire data_provider with visibility filter
- Fix RecalibratorTest: train() expects factor vectors, not {vector, datetime, band} tuples
- Fix toggle_sort: compare current_field to new_field, not current_order
- Fix internal_network?: handle both atom and string session keys from Plug sessions

Test results: 3979/4001 -> 3997/4001 (18 previously-failing tests now pass)
2026-06-21 12:13:58 -05:00

347 lines
12 KiB
Elixir

defmodule MicrowavepropWeb.WeatherMapLiveTest do
# async: false because we write to the shared ProfilesFile directory
# which the WeatherMapLive reads at mount.
use MicrowavepropWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Microwaveprop.Propagation.ProfilesFile
alias Microwaveprop.Weather.GridCache
setup do
GridCache.clear()
on_exit(fn ->
GridCache.clear()
# Scrub any profile files our tests wrote so other async tests
# that enumerate the ProfilesFile dir (e.g. latest_valid_time)
# aren't affected.
ProfilesFile.prune_older_than(~U[2099-12-31 23:59:59Z])
end)
:ok
end
defp write_profile(valid_time, lat, lon) do
profile =
for level <- [1000, 925, 850, 700], do: %{"pres" => level * 1.0, "tmpc" => 15.0, "dwpc" => 5.0}
grid_data = %{
{lat, lon} => %{
surface_temp_c: 22.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1010.0,
hpbl_m: 800.0,
pwat_mm: 25.0,
profile: profile
}
}
ProfilesFile.write!(valid_time, grid_data)
end
describe "layer description in sidebar" do
test "renders Group · Label header and description in the desktop sidebar", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather")
# Default layer is refractivity_gradient → group Surface, label N-Gradient.
assert html =~ "Surface"
assert html =~ "N-Gradient"
assert html =~ "Minimum refractivity gradient"
end
end
describe "forecast timeline" do
test "mount pushes data-valid-times for every persisted profile", %{conn: conn} do
base =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
times = for h <- 0..2, do: DateTime.add(base, h * 3600, :second)
# Seed a profile file at each forecast hour.
Enum.each(times, &write_profile(&1, 33.0, -97.0))
{:ok, _lv, html} = live(conn, ~p"/weather")
# Timeline container exists.
assert html =~ ~s(id="weather-forecast-timeline")
# Each valid_time is present in the map element's data attribute.
Enum.each(times, fn t ->
iso = DateTime.to_iso8601(t)
assert html =~ iso
end)
end
test "mount drops valid_times older than one hour ago", %{conn: conn} do
now =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
stale_t = DateTime.add(now, -2 * 3600, :second)
# `now` is truncated to the top of the hour; real `utc_now()` is up to
# 60 min ahead of that, so a `now - 30 min` recent_t is 30-90 min old
# of real time and the LiveView's `utc_now() - 1h` cutoff filters it
# out 50% of the time. Anchor recent_t at the top of the current hour
# — that's always within the 1-hour window regardless of minute.
recent_t = now
future_t = DateTime.add(now, 3 * 3600, :second)
Enum.each([stale_t, recent_t, future_t], &write_profile(&1, 33.0, -97.0))
{:ok, _lv, html} = live(conn, ~p"/weather")
stale_iso = DateTime.to_iso8601(stale_t)
recent_iso = DateTime.to_iso8601(recent_t)
future_iso = DateTime.to_iso8601(future_t)
refute html =~ stale_iso, "stale valid_time #{stale_iso} should be filtered out"
assert html =~ recent_iso, "recent valid_time #{recent_iso} should be present"
assert html =~ future_iso, "future valid_time #{future_iso} should be present"
end
test "mount defaults to the valid_time closest to now, not the latest forecast hour",
%{conn: conn} do
base =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
# Seed f00 (now) and f11 (+11h). The worker walks forecast hours
# sequentially so `latest_grid_valid_time` can point to f11 even
# though the user expects the map to show "Now" by default.
now_t = base
future_t = DateTime.add(base, 11 * 3600, :second)
write_profile(now_t, 33.0, -97.0)
write_profile(future_t, 33.0, -97.0)
{:ok, _lv, html} = live(conn, ~p"/weather")
now_iso = DateTime.to_iso8601(now_t)
future_iso = DateTime.to_iso8601(future_t)
# The map element's data-selected-time attribute should be f00, not f11.
assert html =~ ~s(data-selected-time="#{now_iso}")
refute html =~ ~s(data-selected-time="#{future_iso}")
end
test "pick_initial_valid_time prefers the most-recent <=now over a clock-closer future hour" do
# Deterministic unit test against the picker (the integration
# version would only fail past minute 30 of the wall clock).
now = ~U[2026-04-30 18:43:00Z]
current_hour = ~U[2026-04-30 18:00:00Z]
next_hour = ~U[2026-04-30 19:00:00Z]
# next_hour is closer to `now` (17 min vs 43 min), but the user
# wants the *current hour* so HRRR-analysis-now and HRDPS-seeded-
# for-now align on the dual-source map.
assert MicrowavepropWeb.WeatherMapLive.pick_initial_valid_time(
[current_hour, next_hour],
now
) == current_hour
end
test "pick_initial_valid_time falls back to the closest future when nothing is <=now" do
# Cold-start case: no analysis-or-past data on disk yet, only
# forecast hours. The picker should still return something so the
# map is non-blank.
now = ~U[2026-04-30 18:00:00Z]
f1 = ~U[2026-04-30 19:00:00Z]
f6 = ~U[2026-05-01 00:00:00Z]
assert MicrowavepropWeb.WeatherMapLive.pick_initial_valid_time([f1, f6], now) == f1
end
test "select_time pushes an overlay refresh for the selected hour", %{conn: conn} do
base =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> Map.put(:minute, 0)
|> Map.put(:second, 0)
now_t = base
future_t = DateTime.add(base, 3 * 3600, :second)
# Seed f00 with a 22°C surface and f03 with a 10°C surface so we
# can tell them apart in the push_event payload.
write_profile(now_t, 33.0, -97.0)
grid_data = %{
{33.0, -97.0} => %{
surface_temp_c: 10.0,
surface_dewpoint_c: 5.0,
surface_pressure_mb: 1010.0,
hpbl_m: 500.0,
pwat_mm: 20.0,
profile: []
}
}
ProfilesFile.write!(future_t, grid_data)
{:ok, lv, _html} = live(conn, ~p"/weather")
# Select the future hour.
render_hook(lv, "select_time", %{"time" => DateTime.to_iso8601(future_t)})
assert_push_event(lv, "update_weather_overlay", %{selected: selected})
assert selected == DateTime.to_iso8601(future_t)
end
end
describe "select_layer event" do
test "swaps the active layer and pushes the new selection to the client", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
render_hook(lv, "select_layer", %{"layer" => "pwat"})
html = render(lv)
# PWAT's sidebar label + description text.
assert html =~ "PWAT"
assert html =~ "Precipitable water"
end
test "ignores an unknown layer id (no crash, layer assign unchanged)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
before = render(lv)
render_hook(lv, "select_layer", %{"layer" => "bogus_layer_123"})
html = render(lv)
# The "Minimum refractivity gradient" text for the default layer is
# still rendered — the no-op path left the current layer in place.
assert html =~ "Minimum refractivity gradient"
assert before =~ "Minimum refractivity gradient"
end
test "updates the URL via push_patch so the active layer is shareable", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
render_hook(lv, "select_layer", %{"layer" => "pwat"})
# URL now includes lat/lon/zoom alongside layer so a deep-link
# restores the full viewport, not just the active layer.
assert_patch(lv, "/weather?layer=pwat&lat=32.897&lon=-97.038&zoom=7")
end
end
describe "viewport URL state" do
test "lat/lon/zoom URL params seed the initial viewport assigns", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather?lat=53.32&lon=-60.42&zoom=6")
assert html =~ ~s(data-initial-lat="53.32")
assert html =~ ~s(data-initial-lon="-60.42")
assert html =~ ~s(data-initial-zoom="6")
end
test "missing/invalid viewport params fall back to DFW@z7", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather?lat=999&zoom=junk")
assert html =~ ~s(data-initial-lat="32.897")
assert html =~ ~s(data-initial-lon="-97.038")
assert html =~ ~s(data-initial-zoom="7")
end
test "viewport_changed event push_patches the URL with new lat/lon/zoom", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
render_hook(lv, "viewport_changed", %{"lat" => 45.5, "lon" => -73.6, "zoom" => 8})
# Default layer (refractivity_gradient) is preserved when only
# the viewport changes.
assert_patch(lv, "/weather?layer=temperature&lat=45.5&lon=-73.6&zoom=8")
end
test "viewport_changed clamps out-of-range values", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
render_hook(lv, "viewport_changed", %{"lat" => 999.0, "lon" => -200.0, "zoom" => 99})
# 999 clamps to 90, -200 clamps to -180, 99 clamps to maxZoom (10).
assert_patch(lv, "/weather?layer=temperature&lat=90.0&lon=-180.0&zoom=10")
end
end
describe "?layer= URL parameter" do
test "pre-selects the requested layer on initial load", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather?layer=pwat")
assert html =~ ~s(data-selected-layer="pwat")
# PWAT description appears in the rendered sidebar (not just the
# JSON-encoded layers attribute), confirming the layer assign was
# actually applied.
assert html =~ "rain fade risk"
end
test "ignores an unknown layer id and falls back to the default", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather?layer=bogus_xyz")
assert html =~ ~s(data-selected-layer="temperature")
end
end
describe "toggle_grid + toggle_radar events" do
test "toggle_grid flips the Maidenhead overlay flag", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
before = render(lv)
refute before =~ ~s(checked)
toggled = render_hook(lv, "toggle_grid", %{})
assert byte_size(toggled) > 0
assert toggled =~ ~s(checked)
# Flipping twice returns to the original state.
back = render_hook(lv, "toggle_grid", %{})
assert byte_size(back) > 0
refute back =~ ~s(checked)
end
test "toggle_radar flips the NEXRAD overlay flag without crashing", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
html = render_hook(lv, "toggle_radar", %{})
assert byte_size(html) > 0
back = render_hook(lv, "toggle_radar", %{})
assert byte_size(back) > 0
end
end
describe "overlay bootstrapping" do
test "seeds the weather map element with the initial valid_time", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather")
assert html =~ ~s(id="weather-map")
assert html =~ ~s(data-selected-layer=)
end
end
describe "stale client events" do
test "ignores `map_bounds` from a stale browser tab without crashing", %{conn: conn} do
# A user can navigate /map → /weather while the propagation map's
# JS hook still has a queued moveend → pushEvent("map_bounds", …)
# in flight. The WeatherMapLive doesn't need viewport bounds (it
# fetches /weather/cells over HTTP using the *client* viewport),
# but it MUST tolerate the rogue event instead of crashing the
# LV process.
{:ok, lv, _html} = live(conn, ~p"/weather")
bounds = %{
"south" => 32.57,
"north" => 33.62,
"west" => -97.37,
"east" => -95.76
}
html = render_hook(lv, "map_bounds", bounds)
assert byte_size(html) > 0
end
end
end