prop/test/microwaveprop_web/live/weather_map_live_test.exs
Graham McIntire 725a7e6bda
WeatherMap: forecast-hour timeline + top-right layer description
Two related UI changes driven by the same data:

* Move the selected-layer description out of the sidebar and into a
  dedicated top-right overlay on the map. The sidebar kept the same
  layer buttons but dropped the descriptive text; the new overlay
  shows group + layer name + description in one card. Mobile still
  shows the description in the collapsible panel since it has no
  top-right free real estate.

* Add a forecast-hour timeline bar at the bottom of the map, matching
  the propagation map. WeatherMapLive enumerates ProfilesFile on mount
  (the grid worker has been persisting f00..f18 on disk since
  commit 07ffcf5), pushes data-valid-times for the JS hook to render
  as buttons, and handles a new select_time event by reading the
  per-hour ProfilesFile on demand via Weather.weather_grid_at/2.

weather_grid_at/2 deliberately skips the GridCache write path for
non-latest hours — caching 18 × 92k rows would add ~300 MB per pod.
A ~2 MB ETF decode per scrub is fast enough for a click.

Subscribes to propagation:pipeline so the timeline picks up new
forecast hours live as they land, without waiting for the full chain
to finish.
2026-04-15 13:57:23 -05:00

114 lines
3.3 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 overlay" do
test "renders the description in a top-right overlay, not in the sidebar", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/weather")
assert html =~ ~s(id="weather-layer-info")
# Default layer is refractivity_gradient — its description should
# appear inside the overlay element.
assert html =~ ~r/id="weather-layer-info".*?Minimum refractivity gradient/s
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 "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
end