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>.*?2<\/td>/s assert panel =~ ~r/Done \(1h\)[^0-9]*<\/th>.*?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