Move the /weather render path off raw HRRR profile decoding + per-cell SoundingParams + WeatherLayers derivation. The dominant cost was re-deriving the same scalar fields on every viewport pan and timeline scrub. Server side: - New Microwaveprop.Weather.ScalarFile persists pre-derived scalar rows on NFS, bucketed into 5°×5° chunk files under <base>/weather_scalars/<iso>/<lat_band>_<lon_band>.etf.gz. Viewport reads only decode the chunks that overlap the requested bounds; point-detail clicks read exactly one chunk. - weather_grid_at/2 and weather_point_detail/3 prefer ScalarFile; ProfilesFile is now the cold-start fallback only. - warm_grid_cache_and_broadcast/1 and warm_grid_cache_from_latest_profile/0 also persist the scalar artifact, and the cold-derive path kicks off a per-valid_time-locked async materialization so the next reader gets the cheap path. - NotifyListener.handle_propagation_ready/1 fires Weather.materialize_scalar_file/1 in a detached Task on the Rust pipeline's NOTIFY propagation_ready, so steady-state forecast hours arrive with their scalar artifact already on disk. - available_weather_valid_times/0 unions ScalarFile + ProfilesFile so the timeline survives an aggressive retention sweep on either side. Browser side (finding #8): - Mount the legend Leaflet control once and patch its inner content on layer changes instead of remove + re-add on every renderLayer. - renderTimeline now keys off the rendered button set: selection-only updates take an applyTimelineSelection patch path that restyles existing buttons in place. Full innerHTML rebuild only fires when timelineData itself changes. Also fixes a credo nesting-depth flag in tile_renderer.ex by extracting interpolate_pair/2 from the inner anonymous function.
226 lines
7.1 KiB
Elixir
226 lines
7.1 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 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"})
|
|
|
|
assert_patched(lv, ~p"/weather?layer=pwat")
|
|
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 "tile overlay bootstrapping" do
|
|
test "renders the weather tile base URL on the map element", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/weather")
|
|
|
|
assert html =~ ~s(data-weather-tile-url="/weather/tiles")
|
|
end
|
|
end
|
|
end
|