prop/test/microwaveprop_web/live/weather_map_live_test.exs
Graham McIntire f99d07bd29
test: push line coverage from 71.7% → 78.67%
Adds ~600 new test cases (2013 → 2613 tests; 170 properties; 0
failures) across 12 new test files plus expansions of eight existing
files. Big lifts per module:

  ContactLive.Mechanism      33 → 100%
  MetricsPlug                54 → ~100%
  Microwaveprop.Release      15.8 → 70%+
  Telemetry                  38 → 76%
  HrrrNativeGridWorker       27.7 → 76%
  ContactLive.Show           34 → 48% (handler + render branches)
  Admin.ContactEditLive      49.7 → 70%+
  GefsFetchWorker            35.8 → ~55%
  IonosphereFetchWorker      56.3 → 75%
  PathLive                   58.9 → 67%
  WeatherMapLive             66.9 → 80.2%
  RoverLive                  0 → 70.3%
  ContactMapLive             59.2 → 89.8%
  ContactMapController       0 → 100%
  Mix tasks (Rust.Golden, Notebook, Backtest, HrrrBackfill,
    HrrrClimatology, HrrrNativeBackfill, NexradBackfill,
    RadarBackfill, ImportContestLogs, PropagationGrid,
    ResetEnrichment, Hrrr.PurgeGridPoints)  0 → ~60-100%

Two incidental fixes made while adding tests:

- ContactLive.Show.handle_event("toggle_flag", ...) was passing
  socket.assigns.current_scope to admin?/1 instead of socket.assigns,
  so admins never matched the pattern and every toggle ran the
  "Admins only." flash branch. Flag now toggles for admins again.

- Commercial.PollWorker.fetch_weather/1 promoted from private to
  @doc'd public so the IEM-fetch + ASOS-upsert path can be tested
  directly without driving perform/1 through live SNMP (which was
  timing out for ~50 s per test in the earlier attempt).

Stable property-test additions cover the dewpoint-from-RH monotonicity,
nearest_run/1 idempotence, and the ionosphere station envelope.
2026-04-23 18:43:18 -05:00

211 lines
6.7 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 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 "select_time replaces the rendered weather data with the new hour's grid", %{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", %{data: data})
assert Enum.any?(data, fn r -> r.temperature == 10.0 end)
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
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)
toggled = render_hook(lv, "toggle_grid", %{})
assert byte_size(toggled) > 0
assert before != toggled or toggled =~ "data-grid"
# Flipping twice returns to the original state.
back = render_hook(lv, "toggle_grid", %{})
assert is_binary(back)
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 is_binary(html)
back = render_hook(lv, "toggle_radar", %{})
assert is_binary(back)
end
end
describe "map_bounds event" do
test "updating the bounds re-scopes the grid query without errors", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/weather")
# Smaller bounds within the initial DFW window.
new_bounds = %{"north" => 33.5, "south" => 32.5, "east" => -96.0, "west" => -98.0}
render_hook(lv, "map_bounds", new_bounds)
# After debouncing the server flushes with a push_event for the grid.
send(lv.pid, :flush_weather_bounds)
render(lv)
# Socket assigns update — we don't pin the grid contents because the
# empty-profiles case returns an empty data list. The smoke test is
# just that the handler doesn't raise.
assert Process.alive?(lv.pid)
end
end
end