prop/test/microwaveprop_web/live/status_live_test.exs
Graham McIntire 234a27b9bc
feat(status): show Rust prop-grid-rs work queue alongside Oban panel
Mirrors the Oban Job Queue Status table with a new section sourced
from the grid_tasks hand-off table. Columns: Running, Queued,
Retrying, Done (1h).

Active rows render an f## badge per in-flight forecast hour and a
detail line below with run_time / fh / attempt so it's obvious which
HRRR cycle the worker is on.

Pipes through the existing 2s debounced refresh plus a new PubSub
subscription to "propagation:updated" — PropagationNotifyListener
fans out the Rust worker's NOTIFY propagation_ready, so the panel
transitions running → done the moment Rust writes the score file.
2026-04-19 17:07:38 -05:00

223 lines
7.3 KiB
Elixir

defmodule MicrowavepropWeb.StatusLiveTest do
use MicrowavepropWeb.ConnCase, async: false
import Microwaveprop.AccountsFixtures
import Phoenix.LiveViewTest
alias Microwaveprop.Cache
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias Microwaveprop.Weather.NarrProfile
setup %{conn: conn} do
Cache.invalidate({MicrowavepropWeb.StatusLive, :count_unprocessed})
Cache.invalidate({MicrowavepropWeb.StatusLive, :stats})
Cache.invalidate({MicrowavepropWeb.StatusLive, :db_stats})
Cache.invalidate({MicrowavepropWeb.StatusLive, :grid_tasks_stats})
user = user_fixture()
{:ok, admin} = user |> Ecto.Changeset.change(%{is_admin: true}) |> Repo.update()
conn = log_in_user(conn, admin)
{:ok, conn: conn}
end
describe "grid_tasks panel — Rust prop-grid-rs worker" do
defp insert_grid_task(attrs) do
id = Ecto.UUID.bingenerate()
now = DateTime.truncate(DateTime.utc_now(), :second)
row =
Map.merge(
%{
id: id,
run_time: ~N[2026-04-19 19:00:00],
forecast_hour: 5,
valid_time: ~N[2026-04-20 00:00:00],
status: "queued",
attempt: 0,
inserted_at: now,
updated_at: now
},
attrs
)
{1, nil} = Repo.insert_all("grid_tasks", [row])
id
end
test "shows an in-flight row with the forecast hour it's working on", %{conn: conn} do
insert_grid_task(%{
status: "running",
forecast_hour: 7,
attempt: 1,
claimed_at: DateTime.truncate(DateTime.utc_now(), :microsecond)
})
{:ok, lv, _html} = live(conn, ~p"/status")
panel = lv |> element("#grid-tasks-panel") |> render()
assert panel =~ "prop-grid-rs"
assert panel =~ "f07"
assert panel =~ "running"
end
test "shows queued and completed counts", %{conn: conn} do
insert_grid_task(%{forecast_hour: 1, status: "queued"})
insert_grid_task(%{forecast_hour: 2, status: "queued"})
insert_grid_task(%{
forecast_hour: 3,
status: "done",
completed_at: DateTime.truncate(DateTime.utc_now(), :microsecond)
})
{:ok, lv, _html} = live(conn, ~p"/status")
panel = lv |> element("#grid-tasks-panel") |> render()
assert panel =~ ~r/Queued[^0-9]*<\/th>.*?<td>2<\/td>/s
assert panel =~ ~r/Done \(1h\)[^0-9]*<\/th>.*?<td>1<\/td>/s
end
end
defp create_narr_candidate do
{:ok, contact} =
%Contact{}
|> Contact.changeset(%{
station1: "W5TEST",
station2: "K5TEST",
qso_timestamp: ~U[2010-06-15 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("300")
})
|> Ecto.Changeset.change(%{hrrr_status: :unavailable})
|> Repo.insert()
contact
end
# NARR fetches land in era5_profiles (table rename is a follow-up migration).
# The status UI labels this "NARR" now.
test "NARR progress bar numerator is actual profile matches not total-minus-candidates", %{conn: conn} do
create_narr_candidate()
# No NARR profile yet — candidates = 1, done = 0
{:ok, lv, _html} = live(conn, ~p"/status")
narr_row = lv |> element(~s|[data-progress-key="narr"]|) |> render()
assert narr_row =~ "NARR"
assert narr_row =~ "0 / 1"
# Insert a matching profile row — done should become 1
{:ok, _} =
Repo.insert(%NarrProfile{
valid_time: ~U[2010-06-15 18:00:00Z],
lat: 33.0,
lon: -97.0,
ducting_detected: false
})
Cache.invalidate({MicrowavepropWeb.StatusLive, :count_unprocessed})
{:ok, lv, _html} = live(conn, ~p"/status")
narr_row = lv |> element(~s|[data-progress-key="narr"]|) |> render()
assert narr_row =~ "1 / 1"
end
# NARR serves pre-2014 contacts only (NCEI archive ends 2014-10-02). A
# post-2014 contact with hrrr_status=:unavailable is a HRRR retry case,
# not a NARR candidate, and must not inflate the NARR denominator.
test "NARR progress excludes post-2014 hrrr-unavailable contacts", %{conn: conn} do
# pre-2014 candidate (counts toward NARR)
create_narr_candidate()
# post-2014 hrrr-unavailable contact (does NOT count toward NARR)
{:ok, _} =
%Contact{}
|> Contact.changeset(%{
station1: "W5POST",
station2: "K5POST",
qso_timestamp: ~U[2020-06-15 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("300")
})
|> Ecto.Changeset.change(%{hrrr_status: :unavailable})
|> Repo.insert()
{:ok, lv, _html} = live(conn, ~p"/status")
narr_row = lv |> element(~s|[data-progress-key="narr"]|) |> render()
assert narr_row =~ "0 / 1"
end
# NARR analyses are archived at 3-hour marks (00/03/06/09/12/15/18/21 UTC).
# `NarrClient.snap_to_analysis_hour/1` floors the QSO timestamp to the
# previous 3-hour mark before fetch. The status numerator query must mirror
# that snap — a naive ±30 min window around the raw qso_timestamp misses
# the stored profile whenever the contact lands more than 30 min past the
# last analysis hour.
test "NARR progress counts contacts whose qso_timestamp doesn't land on a 3-hour analysis mark",
%{conn: conn} do
{:ok, _} =
%Contact{}
|> Contact.changeset(%{
station1: "W5OFF",
station2: "K5OFF",
qso_timestamp: ~U[2010-06-15 18:50:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("300")
})
|> Ecto.Changeset.change(%{hrrr_status: :unavailable})
|> Repo.insert()
{:ok, _} =
Repo.insert(%NarrProfile{
valid_time: ~U[2010-06-15 18:00:00Z],
lat: 33.0,
lon: -97.0,
ducting_detected: false
})
{:ok, lv, _html} = live(conn, ~p"/status")
narr_row = lv |> element(~s|[data-progress-key="narr"]|) |> render()
assert narr_row =~ "1 / 1"
end
# NARR eligibility is defined by qso_timestamp, not hrrr_status. A
# pre-2014 contact whose hrrr_status is still :queued (the ping-pong
# window before reconcile flips it) must still be counted — otherwise
# the denominator misses rows for several cron cycles after submission.
test "NARR candidates include pre-2014 contacts regardless of hrrr_status", %{conn: conn} do
{:ok, _} =
%Contact{}
|> Contact.changeset(%{
station1: "W5QUE",
station2: "K5QUE",
qso_timestamp: ~U[2010-06-15 18:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM12",
grid2: "EM00",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7},
distance_km: Decimal.new("300")
})
|> Ecto.Changeset.change(%{hrrr_status: :queued})
|> Repo.insert()
{:ok, lv, _html} = live(conn, ~p"/status")
narr_row = lv |> element(~s|[data-progress-key="narr"]|) |> render()
assert narr_row =~ "0 / 1"
end
end