- Fix N+1 queries in radio.ex (preload :user) - Add encryption_salt to session options - Consolidate token deletion to single query - Wrap gunzip in try/rescue for corrupt files - Add Cache.match_delete/1 to encapsulate ETS access - Precompute sec_i_factor_ref as module attribute - Guard EXLA.Backend config to dev/test only - Split 3505-line contact_live_test.exs into 4 files - Replace Process.sleep with :sys.get_state synchronization - Replace Process.alive? with DOM output assertions - Clarify router.ex comment for non-live_session routes - Update findings.md to reflect fixes
652 lines
22 KiB
Elixir
652 lines
22 KiB
Elixir
defmodule MicrowavepropWeb.MapLiveTest do
|
|
use MicrowavepropWeb.ConnCase, async: false
|
|
use ExUnitProperties
|
|
|
|
import Phoenix.ConnTest, only: [get: 2]
|
|
import Phoenix.LiveViewTest
|
|
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
alias Microwaveprop.Propagation.ScoreCache
|
|
alias Phoenix.LiveView.Utils
|
|
|
|
describe "mount" do
|
|
test "renders full-page map", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ ~s(id="propagation-map")
|
|
assert html =~ ~s(phx-hook="PropagationMap")
|
|
end
|
|
|
|
test "renders band dropdown with all bands", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ "10 GHz"
|
|
assert html =~ "24 GHz"
|
|
assert html =~ "47 GHz"
|
|
assert html =~ "75 GHz"
|
|
assert html =~ "241 GHz"
|
|
end
|
|
|
|
test "defaults to 10 GHz selected", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ ~s(class="active")
|
|
end
|
|
|
|
test "includes data-selected-band and data-selected-time attributes", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ "data-selected-band"
|
|
assert html =~ "data-selected-time"
|
|
end
|
|
|
|
test "renders grid toggle", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ "Grid squares"
|
|
assert html =~ ~s(phx-click="toggle_grid")
|
|
end
|
|
|
|
test "renders scoring algorithm link", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ "Scoring Algorithm"
|
|
assert html =~ ~s(/algo)
|
|
end
|
|
|
|
test "renders submit QSO link", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ "Submit a Contact"
|
|
assert html =~ ~s(/submit)
|
|
end
|
|
end
|
|
|
|
describe "score data attributes" do
|
|
setup do
|
|
ScoreCache.clear()
|
|
on_exit(fn -> ScoreCache.clear() end)
|
|
:ok
|
|
end
|
|
|
|
test "static HTTP render advertises selected band/time so the client can fetch /scores/cells",
|
|
%{conn: conn} do
|
|
conn = get(conn, ~p"/map")
|
|
|
|
assert conn.status == 200
|
|
body = Phoenix.ConnTest.html_response(conn, 200)
|
|
# data-* attributes seed the JS hook with the band + valid_time
|
|
# to fetch via /scores/cells. No score JSON is baked into the
|
|
# initial HTML anymore.
|
|
assert body =~ ~s(data-selected-band=)
|
|
assert body =~ ~s(data-selected-time=)
|
|
refute body =~ ~s(data-scores=)
|
|
end
|
|
end
|
|
|
|
describe "CONUS-only banner" do
|
|
@banner_copy "This data set currently covers the continental United States only."
|
|
|
|
test "is hidden when no CF lat/lon in session", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
refute html =~ ~s(id="conus-banner")
|
|
refute html =~ @banner_copy
|
|
end
|
|
|
|
test "is hidden when CF lat/lon is inside CONUS (DFW)", %{conn: conn} do
|
|
conn = Phoenix.ConnTest.init_test_session(conn, %{cf_lat: 32.9, cf_lon: -97.0})
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
refute html =~ ~s(id="conus-banner")
|
|
refute html =~ @banner_copy
|
|
end
|
|
|
|
test "is visible when CF lat/lon is outside CONUS (London)", %{conn: conn} do
|
|
conn = Phoenix.ConnTest.init_test_session(conn, %{cf_lat: 51.5, cf_lon: -0.1})
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ ~s(id="conus-banner")
|
|
assert html =~ @banner_copy
|
|
end
|
|
end
|
|
|
|
describe "select_band" do
|
|
test "updates selected band on event", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
html = render_click(lv, "select_band", %{"value" => 24_000})
|
|
|
|
assert html =~ "24 GHz"
|
|
end
|
|
|
|
test "patches the URL with the newly-selected band", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
render_click(lv, "select_band", %{"value" => 47_000})
|
|
|
|
url = assert_patch(lv)
|
|
assert url =~ "band=47000"
|
|
end
|
|
|
|
test "select_band pushes update_band_info with the new band id so the client refetches", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
_ = render(lv)
|
|
render_click(lv, "select_band", %{"value" => 24_000})
|
|
|
|
assert_push_event(lv, "update_band_info", %{band_info: _info, band_mhz: 24_000})
|
|
end
|
|
end
|
|
|
|
describe "property: select_band patches the URL for every configured band" do
|
|
property "each configured band patches to a URL containing band=<mhz>" do
|
|
bands = Enum.map(BandConfig.band_options(), fn {_l, v} -> String.to_integer(v) end)
|
|
|
|
check all(band <- StreamData.member_of(bands), max_runs: 8) do
|
|
conn = Phoenix.ConnTest.build_conn()
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
render_click(lv, "select_band", %{"value" => band})
|
|
|
|
url = assert_patch(lv)
|
|
|
|
assert url =~ "band=#{band}",
|
|
"expected /map patch URL to contain band=#{band}, got #{inspect(url)}"
|
|
end
|
|
end
|
|
end
|
|
|
|
describe "compute_viewshed" do
|
|
test "accepts compute_viewshed event without crashing", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
# Event should be accepted; async computation may fail (no SRTM in test)
|
|
# but the LiveView should not crash
|
|
render_click(lv, "compute_viewshed", %{"lat" => 32.9, "lon" => -97.0})
|
|
# If we get here without crash, the event handler exists
|
|
assert render(lv) =~ "propagation-map"
|
|
end
|
|
end
|
|
|
|
describe "toggle_grid" do
|
|
test "toggles grid visibility", %{conn: conn} do
|
|
{:ok, lv, html} = live(conn, ~p"/map")
|
|
refute html =~ "checked"
|
|
|
|
html = render_click(lv, "toggle_grid")
|
|
assert html =~ "checked"
|
|
|
|
html = render_click(lv, "toggle_grid")
|
|
refute html =~ "checked"
|
|
end
|
|
end
|
|
|
|
describe "toggle_radar" do
|
|
test "flips the radar layer visibility assign", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
refute :sys.get_state(lv.pid).socket.assigns.radar_visible
|
|
render_click(lv, "toggle_radar")
|
|
assert :sys.get_state(lv.pid).socket.assigns.radar_visible
|
|
render_click(lv, "toggle_radar")
|
|
refute :sys.get_state(lv.pid).socket.assigns.radar_visible
|
|
end
|
|
|
|
test "pushes toggle_radar to the client with the new visibility", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
render_click(lv, "toggle_radar")
|
|
|
|
assert_push_event(lv, "toggle_radar", %{visible: true})
|
|
|
|
render_click(lv, "toggle_radar")
|
|
assert_push_event(lv, "toggle_radar", %{visible: false})
|
|
end
|
|
end
|
|
|
|
describe "toggle_grid event payload" do
|
|
test "pushes toggle_grid to the client with the new visibility", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
render_click(lv, "toggle_grid")
|
|
assert_push_event(lv, "toggle_grid", %{visible: true})
|
|
|
|
render_click(lv, "toggle_grid")
|
|
assert_push_event(lv, "toggle_grid", %{visible: false})
|
|
end
|
|
end
|
|
|
|
describe "select_time / set_selected_time" do
|
|
test "set_selected_time accepts an iso8601 string and updates the assign",
|
|
%{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
now_iso = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
|
|
render_hook(lv, "set_selected_time", %{"time" => now_iso})
|
|
|
|
# The handler accepts the event without crashing. Whether the new
|
|
# time is actually adopted depends on valid_times, which is empty
|
|
# in test (no ScoresFiles); that's fine — we're testing the
|
|
# handler path exists and doesn't raise on malformed/new input.
|
|
assert render(lv) =~ "propagation-map"
|
|
end
|
|
|
|
test "select_time event is accepted and doesn't crash the LV",
|
|
%{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
render_click(lv, "select_time", %{"time" => "2026-04-21T22:00:00Z"})
|
|
assert render(lv) =~ "propagation-map"
|
|
end
|
|
|
|
test "select_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
render_click(lv, "select_time", %{"time" => "definitely-not-a-time"})
|
|
assert render(lv) =~ "propagation-map"
|
|
end
|
|
|
|
test "set_selected_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
render_hook(lv, "set_selected_time", %{"time" => "definitely-not-a-time"})
|
|
assert render(lv) =~ "propagation-map"
|
|
end
|
|
|
|
test "select_time with valid iso updates selected_time assign", %{conn: conn} do
|
|
valid_time =
|
|
DateTime.utc_now()
|
|
|> DateTime.truncate(:second)
|
|
|> Map.put(:minute, 0)
|
|
|> Map.put(:second, 0)
|
|
|
|
Propagation.replace_scores(
|
|
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 55, factors: nil}],
|
|
valid_time
|
|
)
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
iso = DateTime.to_iso8601(valid_time)
|
|
render_click(lv, "select_time", %{"time" => iso})
|
|
|
|
assert :sys.get_state(lv.pid).socket.assigns.selected_time == valid_time
|
|
end
|
|
end
|
|
|
|
describe "point_detail" do
|
|
test "handler pushes a `point_detail` event to the client without crashing",
|
|
%{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
# point_detail does no server-rendered DOM update — it calls
|
|
# push_event/3 with the detail payload and a JS hook consumes it.
|
|
# The assertion is that the handler returns cleanly even in the
|
|
# "no HRRR profile" path (dev/test has empty .prop files).
|
|
render_click(lv, "point_detail", %{"lat" => 32.9, "lon" => -97.04})
|
|
assert render(lv) =~ "propagation-map"
|
|
end
|
|
end
|
|
|
|
describe "map_bounds" do
|
|
test "stores the viewport and debounces a forecast preload", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
render_hook(lv, "map_bounds", %{
|
|
"south" => 30.0,
|
|
"north" => 35.0,
|
|
"west" => -100.0,
|
|
"east" => -95.0
|
|
})
|
|
|
|
bounds = :sys.get_state(lv.pid).socket.assigns.bounds
|
|
assert bounds["south"] == 30.0
|
|
assert bounds["north"] == 35.0
|
|
assert bounds["west"] == -100.0
|
|
assert bounds["east"] == -95.0
|
|
end
|
|
end
|
|
|
|
describe "daily outlook strip" do
|
|
test "no longer renders on the main map", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
refute html =~ "daily-outlook"
|
|
refute html =~ "7-day outlook"
|
|
refute html =~ "Extended outlook"
|
|
end
|
|
end
|
|
|
|
describe "data timestamp indicator" do
|
|
setup do
|
|
# Earlier tests in the suite may have written score files into the
|
|
# shared `:propagation_scores_dir` and warmed the 5 s
|
|
# `list_valid_times` cache. Reset both so this test starts from the
|
|
# "no data" state its assertion describes.
|
|
scores_dir = Application.fetch_env!(:microwaveprop, :propagation_scores_dir)
|
|
_ = File.rm_rf(scores_dir)
|
|
|
|
:ets.match_delete(
|
|
:microwaveprop_cache,
|
|
{{Microwaveprop.Propagation.ScoresFile, :list_valid_times, :_, :_}, :_, :_}
|
|
)
|
|
|
|
:ok
|
|
end
|
|
|
|
test "renders a 'Data from …' line on both mobile and desktop sidebars", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ ~s(id="data-timestamp-mobile")
|
|
assert html =~ ~s(id="data-timestamp-desktop")
|
|
# Clean test DB has no scores; the indicator falls back to "No data".
|
|
assert html =~ "No data"
|
|
end
|
|
end
|
|
|
|
describe "URL params" do
|
|
test "band URL param selects the requested band on mount", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map?band=24000")
|
|
assert html =~ ~s(data-selected-band="24000")
|
|
end
|
|
|
|
test "lat/lon/zoom URL params override the default map center/zoom", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map?lat=40.0&lon=-100.0&zoom=5")
|
|
assert html =~ ~s(data-center-lat="40.0")
|
|
assert html =~ ~s(data-center-lon="-100.0")
|
|
assert html =~ ~s(data-zoom="5")
|
|
end
|
|
|
|
test "invalid band falls back to the default", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map?band=notanumber")
|
|
assert html =~ ~s(data-selected-band="10000")
|
|
end
|
|
|
|
test "unknown band MHz falls back to the default", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map?band=99999")
|
|
assert html =~ ~s(data-selected-band="10000")
|
|
end
|
|
|
|
test "malformed lat/lon/zoom are ignored", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map?lat=banana&lon=apple&zoom=over9000")
|
|
assert html =~ ~s(data-zoom="7")
|
|
refute html =~ ~s(data-center-lat="banana")
|
|
end
|
|
|
|
test "out-of-range lat/lon are ignored", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map?lat=500&lon=-999")
|
|
refute html =~ ~s(data-center-lat="500)
|
|
end
|
|
|
|
test "out-of-range zoom falls back to default", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map?zoom=42")
|
|
assert html =~ ~s(data-zoom="7")
|
|
end
|
|
|
|
test "unparseable time is ignored", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map?t=not-a-date")
|
|
# Page renders normally without crashing.
|
|
assert render(lv) =~ "data-selected-time"
|
|
end
|
|
|
|
test "ISO8601 time outside the current forecast window falls back", %{conn: conn} do
|
|
# Year 2000 is well before any valid HRRR data; page should still
|
|
# mount and fall back to the normal 'now' cursor rather than try
|
|
# to fetch scores for an empty time.
|
|
{:ok, _lv, _html} = live(conn, ~p"/map?t=2000-01-01T00:00:00Z")
|
|
end
|
|
|
|
test "every param garbage at once still mounts cleanly", %{conn: conn} do
|
|
{:ok, _lv, html} =
|
|
live(conn, ~p"/map?band=xyz&t=nope&lat=abc&lon=def&zoom=foo")
|
|
|
|
assert html =~ ~s(data-selected-band="10000")
|
|
assert html =~ ~s(data-zoom="7")
|
|
end
|
|
end
|
|
|
|
describe "cursor_for_now/2" do
|
|
alias MicrowavepropWeb.MapLive
|
|
|
|
test "returns nil for an empty list" do
|
|
assert MapLive.cursor_for_now([], DateTime.utc_now()) == nil
|
|
end
|
|
|
|
test "picks the latest time at or before now — never future" do
|
|
# Valid times at 18:00, 19:00, 20:00 UTC. Wall clock 19:35.
|
|
# Nearest-by-abs would round up to 20:00 (25 min future);
|
|
# floor picks 19:00 (35 min past).
|
|
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
|
|
now = ~U[2026-04-18 19:35:00Z]
|
|
|
|
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z]
|
|
end
|
|
|
|
test "matches exact hour when now is on the hour" do
|
|
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]]
|
|
assert MapLive.cursor_for_now(times, ~U[2026-04-18 19:00:00Z]) == ~U[2026-04-18 19:00:00Z]
|
|
end
|
|
|
|
test "falls back to the earliest time when every slot is in the future" do
|
|
times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 21:00:00Z]]
|
|
now = ~U[2026-04-18 19:00:00Z]
|
|
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 20:00:00Z]
|
|
end
|
|
|
|
test "ignores list order" do
|
|
times = [~U[2026-04-18 20:00:00Z], ~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z]]
|
|
now = ~U[2026-04-18 19:35:00Z]
|
|
assert MapLive.cursor_for_now(times, now) == ~U[2026-04-18 19:00:00Z]
|
|
end
|
|
end
|
|
|
|
describe "tracking_now?/2" do
|
|
alias MicrowavepropWeb.MapLive
|
|
|
|
test "true when selected time equals the latest at-or-before-now slot" do
|
|
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
|
|
now = ~U[2026-04-18 19:35:00Z]
|
|
# closest_to_now at 19:35 with these slots is 19:00.
|
|
assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], times, now) == true
|
|
end
|
|
|
|
test "false when selected time is an earlier historical slot" do
|
|
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
|
|
now = ~U[2026-04-18 19:35:00Z]
|
|
assert MapLive.tracking_now?(~U[2026-04-18 18:00:00Z], times, now) == false
|
|
end
|
|
|
|
test "false when selected time is a future forecast slot" do
|
|
times = [~U[2026-04-18 18:00:00Z], ~U[2026-04-18 19:00:00Z], ~U[2026-04-18 20:00:00Z]]
|
|
now = ~U[2026-04-18 19:35:00Z]
|
|
assert MapLive.tracking_now?(~U[2026-04-18 20:00:00Z], times, now) == false
|
|
end
|
|
|
|
test "false when valid_times is empty" do
|
|
assert MapLive.tracking_now?(~U[2026-04-18 19:00:00Z], [], ~U[2026-04-18 19:00:00Z]) == false
|
|
end
|
|
|
|
test "false when selected_time is nil" do
|
|
times = [~U[2026-04-18 19:00:00Z]]
|
|
assert MapLive.tracking_now?(nil, times, ~U[2026-04-18 19:00:00Z]) == false
|
|
end
|
|
end
|
|
|
|
describe "advance_now_cursor handler" do
|
|
setup do
|
|
ScoreCache.clear()
|
|
on_exit(fn -> ScoreCache.clear() end)
|
|
:ok
|
|
end
|
|
|
|
test "advances selected_time to the newly-past hour when tracking now", %{conn: conn} do
|
|
# Seed two adjacent hours so there's somewhere to advance to. The
|
|
# later of the two is the one we want the cursor to snap to once
|
|
# the clock ticks past it.
|
|
previous_hour =
|
|
DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour)
|
|
|
|
current_hour = DateTime.add(previous_hour, 3600, :second)
|
|
|
|
for t <- [previous_hour, current_hour] do
|
|
Propagation.replace_scores(
|
|
[%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}],
|
|
t
|
|
)
|
|
end
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
# Force the LV onto the previous hour and mark it as tracking_now?
|
|
# so the tick handler has a clear advance opportunity.
|
|
:sys.replace_state(lv.pid, fn state ->
|
|
socket =
|
|
state.socket
|
|
|> Utils.assign(:selected_time, previous_hour)
|
|
|> Utils.assign(:tracking_now?, true)
|
|
|
|
%{state | socket: socket}
|
|
end)
|
|
|
|
send(lv.pid, :advance_now_cursor)
|
|
_ = render(lv)
|
|
|
|
assigns = :sys.get_state(lv.pid).socket.assigns
|
|
assert assigns.selected_time == current_hour
|
|
end
|
|
|
|
test "leaves selected_time alone when the user is on a specific hour", %{conn: conn} do
|
|
previous_hour =
|
|
DateTime.utc_now() |> DateTime.truncate(:second) |> Map.merge(%{minute: 0, second: 0}) |> DateTime.add(-1, :hour)
|
|
|
|
current_hour = DateTime.add(previous_hour, 3600, :second)
|
|
|
|
for t <- [previous_hour, current_hour] do
|
|
Propagation.replace_scores(
|
|
[%{lat: 33.0, lon: -97.0, valid_time: t, band_mhz: 10_000, score: 50, factors: nil}],
|
|
t
|
|
)
|
|
end
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
:sys.replace_state(lv.pid, fn state ->
|
|
socket =
|
|
state.socket
|
|
|> Utils.assign(:selected_time, previous_hour)
|
|
|> Utils.assign(:tracking_now?, false)
|
|
|
|
%{state | socket: socket}
|
|
end)
|
|
|
|
send(lv.pid, :advance_now_cursor)
|
|
_ = render(lv)
|
|
|
|
assigns = :sys.get_state(lv.pid).socket.assigns
|
|
assert assigns.selected_time == previous_hour
|
|
end
|
|
end
|
|
|
|
describe "format_data_timestamp/1" do
|
|
alias MicrowavepropWeb.MapLive
|
|
|
|
test "returns 'No data' for nil" do
|
|
assert MapLive.format_data_timestamp(nil) == "No data"
|
|
end
|
|
|
|
test "renders the valid_time and a 'now' suffix when within ±30 minutes" do
|
|
now = DateTime.utc_now()
|
|
label = MapLive.format_data_timestamp(now)
|
|
assert label =~ "UTC"
|
|
assert label =~ "now"
|
|
end
|
|
|
|
test "renders a '+Nh' suffix when the valid_time is in the future" do
|
|
future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second)
|
|
assert MapLive.format_data_timestamp(future) =~ "+3h"
|
|
end
|
|
|
|
test "renders a 'Nh ago' suffix when the valid_time is in the past" do
|
|
past = DateTime.add(DateTime.utc_now(), -4 * 3600, :second)
|
|
assert MapLive.format_data_timestamp(past) =~ "4h ago"
|
|
end
|
|
end
|
|
|
|
describe "propagation_updated handler" do
|
|
setup do
|
|
ScoreCache.clear()
|
|
|
|
on_exit(fn ->
|
|
ScoreCache.clear()
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
test "refreshes valid_times timeline so the client refetches on the new hour", %{conn: conn} do
|
|
valid_time = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
|
|
|
|
Propagation.replace_scores(
|
|
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 40, factors: nil}],
|
|
valid_time
|
|
)
|
|
|
|
Propagation.warm_cache_and_broadcast(10_000, valid_time)
|
|
ScoreCache.sync()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
# Score updates flow client-side via /scores/cells now; the
|
|
# LiveView only republishes the timeline so the client knows
|
|
# which valid_times are available. Verify the timeline push
|
|
# carries the seeded valid_time.
|
|
send(lv.pid, {:propagation_updated, [valid_time]})
|
|
|
|
assert_push_event(lv, "update_timeline", %{times: times})
|
|
assert Enum.any?(times, fn t -> t.time == DateTime.to_iso8601(valid_time) end)
|
|
end
|
|
end
|
|
|
|
describe "pipeline status chip" do
|
|
test "renders the aggregated pipeline status chip on mount", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
# Both the mobile and desktop sidebars carry their own chip.
|
|
assert html =~ ~s(id="pipeline-status-mobile")
|
|
assert html =~ ~s(id="pipeline-status-desktop")
|
|
# With a clean test DB no pipeline workers have ever run.
|
|
assert html =~ ~s(data-pipeline-state="unknown")
|
|
end
|
|
|
|
test "shows the current forecast hour while PropagationGridWorker runs", %{conn: conn} do
|
|
# Put a grid-worker row in executing state so PipelineStatus.current/0
|
|
# reports :running. Bypassing Oban's normal insert keeps the SQL
|
|
# sandbox from running the worker inline.
|
|
now = DateTime.utc_now()
|
|
|
|
Microwaveprop.Repo.insert!(
|
|
struct!(Oban.Job, %{
|
|
state: "executing",
|
|
queue: "propagation",
|
|
worker: "Microwaveprop.Workers.PropagationGridWorker",
|
|
args: %{},
|
|
attempt: 1,
|
|
max_attempts: 3,
|
|
inserted_at: now,
|
|
scheduled_at: now,
|
|
attempted_at: now
|
|
})
|
|
)
|
|
|
|
{:ok, lv, html} = live(conn, ~p"/map")
|
|
# Before any progress message arrives the chip uses the base label.
|
|
assert html =~ "Updating propagation"
|
|
assert html =~ ~s(data-pipeline-state="running")
|
|
|
|
future = DateTime.add(DateTime.utc_now(), 3 * 3600, :second)
|
|
|
|
Phoenix.PubSub.broadcast(
|
|
Microwaveprop.PubSub,
|
|
"propagation:pipeline",
|
|
{:propagation_pipeline_progress, %{forecast_hour: 5, valid_time: future}}
|
|
)
|
|
|
|
html = render(lv)
|
|
assert html =~ "through +3h"
|
|
|
|
Phoenix.PubSub.broadcast(
|
|
Microwaveprop.PubSub,
|
|
"propagation:pipeline",
|
|
{:propagation_pipeline_progress, %{forecast_hour: 2, valid_time: DateTime.utc_now()}}
|
|
)
|
|
|
|
html = render(lv)
|
|
assert html =~ "through now"
|
|
end
|
|
end
|
|
end
|