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) 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 "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 is_binary(html) end end end