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=" 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 Process.alive?(lv.pid) 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 Process.alive?(lv.pid) 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 Process.alive?(lv.pid) 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 Process.alive?(lv.pid) 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 Process.alive?(lv.pid) 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