The main map page now reads band, selected time, and map center/zoom from URL params and updates the URL as the user pans, zooms, picks a band, or scrubs the timeline. Pan/zoom use push_patch replace-mode so browser history isn't spammed, while band and time changes push fresh entries. Also fix the 'Up to date · just now ago' pipeline chip — 'just now' already reads as an instant so the trailing 'ago' is redundant. Any older duration keeps it.
313 lines
11 KiB
Elixir
313 lines
11 KiB
Elixir
defmodule MicrowavepropWeb.MapLiveTest do
|
|
use MicrowavepropWeb.ConnCase, async: false
|
|
|
|
import Phoenix.LiveViewTest
|
|
|
|
alias Microwaveprop.Propagation
|
|
alias Microwaveprop.Propagation.ScoreCache
|
|
|
|
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-scores attribute", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ "data-scores"
|
|
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 "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
|
|
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 "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
|
|
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
|
|
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 "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 "pushes freshly-written scores when the cache still holds stale ones", %{conn: conn} do
|
|
# Use a valid_time close enough to now that available_valid_times
|
|
# keeps it in the timeline.
|
|
valid_time = DateTime.utc_now() |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
|
|
|
|
# Seed the on-disk store and warm the cache with the same content
|
|
# so the map has a baseline to render on mount.
|
|
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")
|
|
|
|
# Simulate a new forecast-hour compute: rewrite the .ntms file on
|
|
# disk to the updated score, but do *not* touch the cache. This
|
|
# reproduces the race where the cache_refresh message has not yet
|
|
# been applied by the time MapLive handles propagation_updated.
|
|
Propagation.replace_scores(
|
|
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 90, factors: nil}],
|
|
valid_time
|
|
)
|
|
|
|
# Deliver the propagation_updated PubSub message directly.
|
|
send(lv.pid, {:propagation_updated, [valid_time]})
|
|
|
|
# The map must emit the new score (90), not the stale cached one (40).
|
|
assert_push_event(lv, "update_scores", %{scores: scores})
|
|
assert Enum.any?(scores, fn s -> s.score == 90 end)
|
|
refute Enum.any?(scores, fn s -> s.score == 40 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
|