Two related fixes so the main map reliably picks up new binary propagation score files as soon as PropagationGridWorker writes them. 1. Propagation.available_valid_times/1 previously preferred ScoreCache over ScoresFile, using the cache as an index of what was available. The cache is a lazy ETS of whatever hours have been fetched or broadcast, which is a strict subset of what's on disk. A new forecast hour landing on disk while the cache was warm with older entries was invisible to the timeline until the cache happened to catch up. Read directly from ScoresFile so the disk store is the source of truth. 2. Add Propagation.scores_at_fresh/3 that always reads the .ntms file and overwrites the cache entry, and use it from MapLive's propagation_updated handler. PropagationGridWorker publishes the cache_refresh on `propagation:cache` and the timeline ping on `propagation:updated` as separate PubSub broadcasts, so by the time MapLive runs through scores_at the ScoreCache GenServer may not have processed the refresh yet — fetch_bounds then returns the previous chain's bytes. scores_at_fresh takes disk as the source of truth for the refresh path and warms the cache as a side effect so subsequent readers see the new data.
240 lines
7.8 KiB
Elixir
240 lines
7.8 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 "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 "antenna height" do
|
|
test "renders antenna height input with default 33 ft", %{conn: conn} do
|
|
{:ok, _lv, html} = live(conn, ~p"/map")
|
|
assert html =~ ~s(name="height_ft")
|
|
assert html =~ ~s(value="33")
|
|
assert html =~ "ft"
|
|
end
|
|
|
|
test "updates antenna height on change", %{conn: conn} do
|
|
{:ok, lv, _html} = live(conn, ~p"/map")
|
|
|
|
html = render_change(lv, "set_antenna_height", %{"height_ft" => "20"})
|
|
assert html =~ ~s(value="20")
|
|
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 "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 "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")
|
|
|
|
Phoenix.PubSub.broadcast(
|
|
Microwaveprop.PubSub,
|
|
"propagation:pipeline",
|
|
{:propagation_pipeline_progress, %{forecast_hour: 3, valid_time: ~U[2026-04-14 18:00:00Z]}}
|
|
)
|
|
|
|
html = render(lv)
|
|
assert html =~ "+3h"
|
|
|
|
Phoenix.PubSub.broadcast(
|
|
Microwaveprop.PubSub,
|
|
"propagation:pipeline",
|
|
{:propagation_pipeline_progress, %{forecast_hour: 0, valid_time: ~U[2026-04-14 15:00:00Z]}}
|
|
)
|
|
|
|
html = render(lv)
|
|
assert html =~ "now"
|
|
end
|
|
end
|
|
end
|