+
{info}
-
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex
index 0ef1f19e..2d965acd 100644
--- a/lib/microwaveprop_web/router.ex
+++ b/lib/microwaveprop_web/router.ex
@@ -221,13 +221,13 @@ defmodule MicrowavepropWeb.Router do
scope "/", MicrowavepropWeb do
pipe_through :browser
+ # Plain controller routes β outside live_session because they send raw
+ # responses (JSON, YAML, markdown) or render static HTML directly and
+ # don't need the on_mount hooks that set up LiveView session assigns.
get "/", PageController, :home
get "/api/contacts/map", ContactMapController, :show
get "/weather/cells", WeatherTileController, :cells
get "/scores/cells", ScoresController, :cells
-
- # Static API doc artifacts. Outside the live_session so the
- # raw-format endpoints don't get the LiveView root layout.
get "/docs/api/openapi.yaml", ApiDocsController, :openapi_yaml
get "/docs/api/README.md", ApiDocsController, :readme_markdown
diff --git a/priv/repo/migrations/20260529203624_add_enrichment_query_qso_timestamp_indexes.exs b/priv/repo/migrations/20260529203624_add_enrichment_query_qso_timestamp_indexes.exs
index 9459e799..6908d9ae 100644
--- a/priv/repo/migrations/20260529203624_add_enrichment_query_qso_timestamp_indexes.exs
+++ b/priv/repo/migrations/20260529203624_add_enrichment_query_qso_timestamp_indexes.exs
@@ -4,22 +4,27 @@ defmodule Microwaveprop.Repo.Migrations.AddEnrichmentQueryQsoTimestampIndexes do
def change do
create index(:contacts, [:qso_timestamp],
where: "weather_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
- name: :contacts_weather_enrichment_qso_idx)
+ name: :contacts_weather_enrichment_qso_idx
+ )
create index(:contacts, [:qso_timestamp],
where: "hrrr_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
- name: :contacts_hrrr_enrichment_qso_idx)
+ name: :contacts_hrrr_enrichment_qso_idx
+ )
create index(:contacts, [:qso_timestamp],
where: "terrain_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
- name: :contacts_terrain_enrichment_qso_idx)
+ name: :contacts_terrain_enrichment_qso_idx
+ )
create index(:contacts, [:qso_timestamp],
where: "iemre_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
- name: :contacts_iemre_enrichment_qso_idx)
+ name: :contacts_iemre_enrichment_qso_idx
+ )
create index(:contacts, [:qso_timestamp],
where: "radar_status IN ('pending', 'failed') AND pos1 IS NOT NULL",
- name: :contacts_radar_enrichment_qso_idx)
+ name: :contacts_radar_enrichment_qso_idx
+ )
end
end
diff --git a/test/microwaveprop/cache_test.exs b/test/microwaveprop/cache_test.exs
index ab460727..e6c7f045 100644
--- a/test/microwaveprop/cache_test.exs
+++ b/test/microwaveprop/cache_test.exs
@@ -47,9 +47,8 @@ defmodule Microwaveprop.CacheTest do
describe "sweep/0" do
test "removes expired entries" do
Cache.put(:expired, "old", 1)
- Process.sleep(5)
+ Cache.sweep()
- assert Cache.sweep() >= 1
assert :ets.lookup(:microwaveprop_cache, :expired) == []
end
@@ -65,10 +64,7 @@ defmodule Microwaveprop.CacheTest do
Cache.put(:expired_a, 1, 1)
Cache.put(:expired_b, 2, 1)
Cache.put(:fresh, 3, 60_000)
- Process.sleep(5)
-
- deleted = Cache.sweep()
- assert deleted >= 2
+ Cache.sweep()
assert :ets.lookup(:microwaveprop_cache, :expired_a) == []
assert :ets.lookup(:microwaveprop_cache, :expired_b) == []
diff --git a/test/microwaveprop/propagation/freshness_monitor_test.exs b/test/microwaveprop/propagation/freshness_monitor_test.exs
index 78280f4c..eb93f1ee 100644
--- a/test/microwaveprop/propagation/freshness_monitor_test.exs
+++ b/test/microwaveprop/propagation/freshness_monitor_test.exs
@@ -151,7 +151,6 @@ defmodule Microwaveprop.Propagation.FreshnessMonitorTest do
pid = start_supervised!(FreshnessMonitor)
assert is_pid(pid)
- assert Process.alive?(pid)
assert Process.whereis(FreshnessMonitor) == pid
# Force the init-time :check to be processed by issuing a
diff --git a/test/microwaveprop/propagation/notify_listener_test.exs b/test/microwaveprop/propagation/notify_listener_test.exs
index 72765633..1ae5c0a1 100644
--- a/test/microwaveprop/propagation/notify_listener_test.exs
+++ b/test/microwaveprop/propagation/notify_listener_test.exs
@@ -163,7 +163,7 @@ defmodule Microwaveprop.Propagation.NotifyListenerTest do
end
defp wait_until_step(fun, deadline) do
- Process.sleep(20)
+ _ = :sys.get_state(pid)
cond do
fun.() -> true
diff --git a/test/microwaveprop/pskr/client_test.exs b/test/microwaveprop/pskr/client_test.exs
index 9d4a5841..e38e458a 100644
--- a/test/microwaveprop/pskr/client_test.exs
+++ b/test/microwaveprop/pskr/client_test.exs
@@ -69,24 +69,21 @@ defmodule Microwaveprop.Pskr.ClientTest do
# state.socket is nil, but :tcp pattern matches when socket matches state.socket.
# Since state.socket is nil, this falls through to the catch-all handle_info.
send(pid, {:tcp, :stale_port, "data"})
- Process.sleep(20)
- assert Process.alive?(pid)
+ _ = :sys.get_state(pid)
GenServer.stop(pid)
end
test "tcp_closed for a stale socket is ignored", %{placeholder: _} do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:tcp_closed, :stale_port})
- Process.sleep(20)
- assert Process.alive?(pid)
+ _ = :sys.get_state(pid)
GenServer.stop(pid)
end
test "tcp_error for a stale socket is ignored", %{placeholder: _} do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:tcp_error, :stale_port, :reason})
- Process.sleep(20)
- assert Process.alive?(pid)
+ _ = :sys.get_state(pid)
GenServer.stop(pid)
end
end
@@ -95,11 +92,7 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "schedules a connect attempt for a standby (immediate :continue, :connect)" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, :reconnect)
- Process.sleep(50)
- # The handle_info(:reconnect) returns {:noreply, state, {:continue, :connect}}.
- # The connect will fail (no MQTT broker reachable) and schedule another retry.
- # We just verify the process didn't crash on receipt.
- assert Process.alive?(pid)
+ _ = :sys.get_state(pid)
GenServer.stop(pid)
end
end
@@ -108,8 +101,9 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "is graceful when state has no socket" do
{:ok, pid} = GenServer.start_link(Client, [])
# Just normal stop β terminate(_, %{socket: nil_or_other})
+ ref = Process.monitor(pid)
GenServer.stop(pid)
- refute Process.alive?(pid)
+ assert_receive {:DOWN, ^ref, :process, ^pid, _reason}, 1000
end
end
@@ -117,7 +111,7 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "nodeup re-runs election (stays standby while name is taken)" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:nodeup, :test@nowhere})
- Process.sleep(50)
+ _ = :sys.get_state(pid)
state = :sys.get_state(pid)
assert state.role == :standby
@@ -127,7 +121,7 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "nodedown re-runs election (stays standby while name is taken)" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, {:nodedown, :test@nowhere})
- Process.sleep(50)
+ _ = :sys.get_state(pid)
state = :sys.get_state(pid)
assert state.role == :standby
@@ -137,16 +131,14 @@ defmodule Microwaveprop.Pskr.ClientTest do
test "unrelated info messages are ignored" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, :unrelated_message)
- Process.sleep(20)
-
- assert Process.alive?(pid)
+ _ = :sys.get_state(pid)
GenServer.stop(pid)
end
test ":ping with no socket is a no-op" do
{:ok, pid} = GenServer.start_link(Client, [])
send(pid, :ping)
- Process.sleep(20)
+ _ = :sys.get_state(pid)
state = :sys.get_state(pid)
assert state.socket == nil
GenServer.stop(pid)
diff --git a/test/microwaveprop_web/live/contact_live/index_test.exs b/test/microwaveprop_web/live/contact_live/index_test.exs
new file mode 100644
index 00000000..10488be6
--- /dev/null
+++ b/test/microwaveprop_web/live/contact_live/index_test.exs
@@ -0,0 +1,240 @@
+defmodule MicrowavepropWeb.ContactLive.IndexTest do
+ use MicrowavepropWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Microwaveprop.Radio.Contact
+ alias Microwaveprop.Repo
+ alias Microwaveprop.Terrain.ElevationClient
+ alias Microwaveprop.Weather.HrrrNativeProfile
+ alias Microwaveprop.Weather.NarrProfile
+
+ setup do
+ Req.Test.stub(ElevationClient, fn conn ->
+ Req.Test.json(conn, [])
+ end)
+
+ Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
+ Plug.Conn.send_resp(conn, 404, "not found")
+ end)
+
+ Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
+ Req.Test.text(conn, "")
+ end)
+
+ :ok
+ end
+
+ defp create_contact(attrs \\ %{}) do
+ default = %{
+ station1: "W5XD",
+ station2: "K5TR",
+ qso_timestamp: ~U[2026-03-28 18:00:00Z],
+ mode: "CW",
+ band: Decimal.new("1296"),
+ grid1: "EM12",
+ grid2: "EM00",
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("295")
+ }
+
+ merged = Map.merge(default, attrs)
+ {user_id, changeset_attrs} = Map.pop(merged, :user_id)
+
+ {:ok, contact} =
+ %Contact{user_id: user_id}
+ |> Contact.changeset(changeset_attrs)
+ |> Repo.insert()
+
+ contact
+ end
+
+ describe "Index" do
+ test "renders page with Contacts heading", %{conn: conn} do
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ "Contacts"
+ end
+
+ test "shows total contact count in subtitle", %{conn: conn} do
+ create_contact()
+ create_contact(%{station1: "N5AC"})
+ create_contact(%{station1: "K5TR"})
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ "3 total"
+ end
+
+ test "table shows contact data", %{conn: conn} do
+ create_contact()
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ "W5XD"
+ assert html =~ "K5TR"
+ assert html =~ "CW"
+ assert html =~ "1296"
+ end
+
+ test "row links to detail page", %{conn: conn} do
+ contact = create_contact()
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ ~p"/contacts/#{contact.id}"
+ end
+
+ test "sort params order the table", %{conn: conn} do
+ create_contact(%{station1: "ZZ9ZZ"})
+ create_contact(%{station1: "AA1AA"})
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts?sort_params[station1]=asc")
+
+ aa_pos = html |> :binary.match("AA1AA") |> elem(0)
+ zz_pos = html |> :binary.match("ZZ9ZZ") |> elem(0)
+ assert aa_pos < zz_pos
+ end
+
+ test "search by single callsign matches either station", %{conn: conn} do
+ create_contact(%{station1: "W5LUA", station2: "W5HN"})
+ create_contact(%{station1: "K5TR", station2: "N5AC"})
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts?search=W5LUA")
+ assert html =~ "W5LUA"
+ refute html =~ "N5AC"
+ end
+
+ test "pagination reaches later pages", %{conn: conn} do
+ for i <- 1..25 do
+ ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
+ create_contact(%{qso_timestamp: ts, station1: "W#{i}TEST"})
+ end
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ "Page"
+
+ {:ok, _lv, html2} = live(conn, ~p"/contacts?page=2")
+ assert html2 =~ "Page"
+ end
+
+ test "renders a per-month bar chart that sums across years", %{conn: conn} do
+ create_contact(%{qso_timestamp: ~U[2025-03-15 12:00:00Z], station1: "W5MAR"})
+ create_contact(%{qso_timestamp: ~U[2026-03-22 12:00:00Z], station1: "K5MAR"})
+ create_contact(%{qso_timestamp: ~U[2026-07-10 12:00:00Z], station1: "W5JUL"})
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+
+ assert html =~ "Contacts by month"
+ assert html =~ ~s|data-month="3"|
+ assert html =~ ~s|data-month-count="2"|
+ assert html =~ ~s|data-month="7"|
+ assert html =~ ~s|data-month-count="1"|
+ assert html =~ ~s|data-month="1"|
+ assert html =~ ~s|data-month-count="0"|
+ end
+ end
+
+ describe "Index private visibility" do
+ import Microwaveprop.AccountsFixtures
+
+ test "anonymous viewer does not see private contacts in the table", %{conn: conn} do
+ _private = create_contact(%{station1: "W5PRV", private: true})
+ _public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+
+ assert html =~ "W5PUB"
+ refute html =~ "W5PRV"
+ end
+
+ test "owner sees their own private inline with Yes badge", %{conn: conn} do
+ owner = user_fixture()
+ _private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
+ _public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
+ conn = log_in_user(conn, owner)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+
+ assert html =~ "W5PRV"
+ assert html =~ "W5PUB"
+ assert html =~ "badge-warning"
+ end
+
+ test "admin sees all private contacts", %{conn: conn} do
+ admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
+ _private = create_contact(%{station1: "W5PRV", private: true})
+ _public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
+ conn = log_in_user(conn, admin)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ "W5PRV"
+ assert html =~ "W5PUB"
+ end
+
+ test "non-owning user does not see others' private", %{conn: conn} do
+ owner = user_fixture()
+ viewer = user_fixture()
+ _private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
+ _public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
+ conn = log_in_user(conn, viewer)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ "W5PUB"
+ refute html =~ "W5PRV"
+ end
+ end
+
+ describe "Index private column visibility" do
+ import Microwaveprop.AccountsFixtures
+
+ test "column hidden for anonymous viewer when no private contacts exist", %{conn: conn} do
+ _public = create_contact(%{station1: "W5PUB"})
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ refute html =~ ">Private<"
+ end
+
+ test "column hidden for anonymous viewer even when others have private contacts", %{conn: conn} do
+ _private = create_contact(%{station1: "W5PRV", private: true})
+ _public = create_contact(%{station1: "W5PUB"})
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ refute html =~ ">Private<"
+ end
+
+ test "column hidden for a user who has no private contacts of their own", %{conn: conn} do
+ owner = user_fixture()
+ viewer = user_fixture()
+ _others_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
+ conn = log_in_user(conn, viewer)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ refute html =~ ">Private<"
+ end
+
+ test "column visible for a user with at least one of their own private contacts", %{conn: conn} do
+ owner = user_fixture()
+ _private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
+ conn = log_in_user(conn, owner)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ ">Private<"
+ end
+
+ test "column hidden for admin when there are no private contacts anywhere", %{conn: conn} do
+ admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
+ _public = create_contact(%{station1: "W5PUB"})
+ conn = log_in_user(conn, admin)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ refute html =~ ">Private<"
+ end
+
+ test "column visible for admin as soon as any private contact exists", %{conn: conn} do
+ admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
+ _private = create_contact(%{station1: "W5PRV", private: true})
+ conn = log_in_user(conn, admin)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts")
+ assert html =~ ">Private<"
+ end
+ end
+end
diff --git a/test/microwaveprop_web/live/contact_live/show_coverage_test.exs b/test/microwaveprop_web/live/contact_live/show_coverage_test.exs
new file mode 100644
index 00000000..06174706
--- /dev/null
+++ b/test/microwaveprop_web/live/contact_live/show_coverage_test.exs
@@ -0,0 +1,1809 @@
+defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
+ use MicrowavepropWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Microwaveprop.Radio.Contact
+ alias Microwaveprop.Repo
+ alias Microwaveprop.Terrain.ElevationClient
+ alias Microwaveprop.Weather.HrrrNativeProfile
+ alias Microwaveprop.Weather.NarrProfile
+
+ setup do
+ Req.Test.stub(ElevationClient, fn conn ->
+ Req.Test.json(conn, [])
+ end)
+
+ Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
+ Plug.Conn.send_resp(conn, 404, "not found")
+ end)
+
+ Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
+ Req.Test.text(conn, "")
+ end)
+
+ :ok
+ end
+
+ defp create_contact(attrs \\ %{}) do
+ default = %{
+ station1: "W5XD",
+ station2: "K5TR",
+ qso_timestamp: ~U[2026-03-28 18:00:00Z],
+ mode: "CW",
+ band: Decimal.new("1296"),
+ grid1: "EM12",
+ grid2: "EM00",
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("295")
+ }
+
+ merged = Map.merge(default, attrs)
+ {user_id, changeset_attrs} = Map.pop(merged, :user_id)
+
+ {:ok, contact} =
+ %Contact{user_id: user_id}
+ |> Contact.changeset(changeset_attrs)
+ |> Repo.insert()
+
+ contact
+ end
+
+ describe "Show additional branch coverage" do
+ use ExUnitProperties
+
+ import Microwaveprop.AccountsFixtures
+
+ alias Microwaveprop.Propagation.BandConfig
+ alias Microwaveprop.Propagation.ScoreCache
+ alias Microwaveprop.Propagation.Scorer
+ alias Microwaveprop.Radio.ContactCommonVolumeRadar
+ alias Microwaveprop.Radio.ContactEdit
+ alias Microwaveprop.Terrain.TerrainProfile
+ alias Microwaveprop.Weather.HrrrProfile
+
+ defp admin_fixture! do
+ user = user_fixture()
+ {:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true})
+ user
+ end
+
+ setup do
+ ScoreCache.clear()
+ :ok
+ end
+
+ test "factor notes render every note-branch for a 10 GHz contact", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ qso_timestamp: ~U[2026-03-28 18:00:00Z]
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 12.0, "hght" => 100.0}
+ ],
+ hpbl_m: 1200.0,
+ pwat_mm: 32.0,
+ surface_temp_c: 22.0,
+ surface_dewpoint_c: 12.0,
+ surface_pressure_mb: 1020.0,
+ surface_refractivity: 340.0,
+ min_refractivity_gradient: -160.0,
+ ducting_detected: true
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "beneficial at this band"
+ assert html =~ "ducting"
+ assert html =~ "(high)"
+ assert html =~ "summer peak band"
+ assert html =~ "refractivity aid"
+ assert html =~ "None"
+ end
+
+ test "factor notes render 'absorption' and 'winter peak' branches at 24 GHz", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("24000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ qso_timestamp: ~U[2026-01-15 18:00:00Z]
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 5.0, "dwpc" => -2.0, "hght" => 100.0}
+ ],
+ hpbl_m: 1200.0,
+ pwat_mm: 28.0,
+ surface_temp_c: 5.0,
+ surface_dewpoint_c: -2.0,
+ surface_pressure_mb: 1000.0,
+ surface_refractivity: 310.0,
+ min_refractivity_gradient: -50.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "harmful at this band"
+ assert html =~ "absorption"
+ assert html =~ "winter peak band"
+ assert html =~ "(low)"
+ end
+
+ test "td_note renders 'near saturation' when dewpoint hugs temperature", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 19.5, "hght" => 100.0}],
+ hpbl_m: 800.0,
+ pwat_mm: 25.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 19.5,
+ surface_pressure_mb: 1010.0,
+ surface_refractivity: 340.0,
+ min_refractivity_gradient: -90.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "near saturation"
+ assert html =~ "enhanced"
+ assert html =~ "(moderate)"
+ end
+
+ test "td_note renders 'dry' branch for a large T-Td depression", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 30.0, "dwpc" => 0.0, "hght" => 100.0}],
+ hpbl_m: 2200.0,
+ pwat_mm: 8.0,
+ surface_temp_c: 30.0,
+ surface_dewpoint_c: 0.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 280.0,
+ min_refractivity_gradient: -40.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "(dry)"
+ assert html =~ "(normal)"
+ end
+
+ test "BLOCKED terrain with ducting selects the 'atmospheric ducting' mechanism copy", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}],
+ hpbl_m: 800.0,
+ pwat_mm: 30.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 15.0,
+ surface_pressure_mb: 1015.0,
+ surface_refractivity: 340.0,
+ min_refractivity_gradient: -180.0,
+ ducting_detected: true
+ })
+
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
+ max_elevation_m: 800.0,
+ min_clearance_m: -50.0,
+ diffraction_db: 22.0,
+ fresnel_hit_count: 5,
+ obstructed_count: 4,
+ verdict: "BLOCKED"
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "tropospheric duct" or html =~ "atmospheric ducting"
+ end
+
+ test "BLOCKED terrain with enhanced refraction but no ducting uses the 'dN/dh' copy", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
+ hpbl_m: 1200.0,
+ pwat_mm: 20.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 10.0,
+ surface_pressure_mb: 1015.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -140.0,
+ ducting_detected: false
+ })
+
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
+ max_elevation_m: 800.0,
+ min_clearance_m: -20.0,
+ diffraction_db: 18.0,
+ fresnel_hit_count: 3,
+ obstructed_count: 2,
+ verdict: "BLOCKED"
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "dN/dh ="
+ assert html =~ "Enhanced refraction"
+ end
+
+ test "BLOCKED terrain with no ducting or refraction falls back to diffraction copy", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
+ hpbl_m: 1200.0,
+ pwat_mm: 20.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 10.0,
+ surface_pressure_mb: 1015.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -30.0,
+ ducting_detected: false
+ })
+
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
+ max_elevation_m: 800.0,
+ min_clearance_m: -20.0,
+ diffraction_db: 14.5,
+ fresnel_hit_count: 3,
+ obstructed_count: 2,
+ verdict: "BLOCKED"
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "diffraction loss"
+ assert html =~ "knife-edge diffraction"
+ end
+
+ test "clear path with long distance + ducting renders 'extending range' copy", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("295")
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 20.0, "hght" => 100.0}],
+ hpbl_m: 700.0,
+ pwat_mm: 30.0,
+ surface_temp_c: 22.0,
+ surface_dewpoint_c: 20.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 350.0,
+ min_refractivity_gradient: -200.0,
+ ducting_detected: true
+ })
+
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
+ max_elevation_m: 400.0,
+ min_clearance_m: 50.0,
+ diffraction_db: 0.0,
+ fresnel_hit_count: 0,
+ obstructed_count: 0,
+ verdict: "CLEAR"
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Ducting conditions present"
+ assert html =~ "extended range" or html =~ "extending range"
+ end
+
+ test "clear path with enhanced refraction but no ducting hits the long-path branch", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("200")
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
+ hpbl_m: 1200.0,
+ pwat_mm: 20.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 10.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -140.0,
+ ducting_detected: false
+ })
+
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
+ max_elevation_m: 400.0,
+ min_clearance_m: 50.0,
+ diffraction_db: 0.0,
+ fresnel_hit_count: 0,
+ obstructed_count: 0,
+ verdict: "CLEAR"
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Enhanced refraction present"
+ end
+
+ test "admin edit of private toggle and height triggers the diff branch", %{conn: conn} do
+ contact = create_contact()
+ admin = admin_fixture!()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => contact.station1,
+ "station2" => contact.station2,
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => contact.mode,
+ "height1_ft" => "35",
+ "private" => "true"
+ }
+ })
+
+ assert html =~ "Contact updated"
+
+ refreshed = Repo.get!(Contact, contact.id)
+ assert refreshed.height1_ft == 35
+ assert refreshed.private == true
+ end
+
+ test "admin edit with no changes flashes 'No changes detected'", %{conn: conn} do
+ contact = create_contact()
+ admin = admin_fixture!()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => contact.station1,
+ "station2" => contact.station2,
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => contact.mode
+ }
+ })
+
+ assert html =~ "No changes detected"
+ end
+
+ test "owner submit with identical values returns no-changes flash", %{conn: conn} do
+ owner = user_fixture()
+
+ {:ok, contact} =
+ %Contact{}
+ |> Contact.changeset(%{
+ station1: owner.callsign,
+ station2: "K5TR",
+ qso_timestamp: ~U[2026-03-28 18:00:00Z],
+ mode: "CW",
+ band: Decimal.new("1296"),
+ grid1: "EM12",
+ grid2: "EM00",
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("295"),
+ user_submitted: true,
+ submitter_email: owner.email
+ })
+ |> Ecto.Changeset.change(user_id: owner.id)
+ |> Repo.insert()
+
+ conn = log_in_user(conn, owner)
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => contact.station1,
+ "station2" => contact.station2,
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => contact.mode
+ }
+ })
+
+ assert html =~ "No changes detected"
+ end
+
+ test "stranger submit with an invalid mode surfaces the changeset error flash", %{conn: conn} do
+ contact = create_contact()
+ stranger = user_fixture()
+ conn = log_in_user(conn, stranger)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => contact.station1,
+ "station2" => contact.station2,
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => "BOGUS"
+ }
+ })
+
+ assert html =~ "Could not submit edit"
+ assert Repo.aggregate(ContactEdit, :count, :id) == 0
+ end
+
+ test "internal_network? false when no session IP is recorded", %{conn: conn} do
+ contact = create_contact()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ refute :sys.get_state(lv.pid).socket.assigns.can_enqueue
+ end
+
+ test "internal_network? false for an IP just outside the enqueue subnet", %{conn: conn} do
+ conn = %{conn | remote_ip: {8, 8, 8, 8}}
+ contact = create_contact()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ refute :sys.get_state(lv.pid).socket.assigns.can_enqueue
+ end
+
+ test "internal_network? true when session IP sits inside the enqueue subnet", %{conn: conn} do
+ conn = %{conn | remote_ip: {172, 56, 0, 10}}
+ contact = create_contact()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ assert :sys.get_state(lv.pid).socket.assigns.can_enqueue
+ end
+
+ test "expanded_soundings MapSet exposes the skew-T table when a sounding is expanded",
+ %{conn: conn} do
+ alias Microwaveprop.Weather
+ alias Microwaveprop.Weather.Sounding
+
+ contact = create_contact()
+
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: "KFWD",
+ station_type: "sounding",
+ name: "Fort Worth",
+ lat: 32.83,
+ lon: -97.30
+ })
+
+ {:ok, sounding} =
+ %Sounding{}
+ |> Sounding.changeset(%{
+ station_id: station.id,
+ observed_at: ~U[2026-03-28 12:00:00Z],
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
+ %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
+ ],
+ level_count: 2,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 15.0
+ })
+ |> Repo.insert()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ html_open = render_click(lv, "toggle_profile", %{"id" => sounding.id})
+ assert html_open =~ "Wind Dir"
+
+ html_closed = render_click(lv, "toggle_profile", %{"id" => sounding.id})
+ refute html_closed =~ "Wind Dir"
+ end
+
+ test "load_pending_edit returns the existing edit for a logged-in user", %{conn: conn} do
+ contact = create_contact()
+ user = user_fixture()
+
+ Repo.insert!(%ContactEdit{
+ contact_id: contact.id,
+ user_id: user.id,
+ proposed_changes: %{"station2" => "K9EDIT"},
+ status: :pending
+ })
+
+ conn = log_in_user(conn, user)
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ assigns = :sys.get_state(lv.pid).socket.assigns
+ assert %ContactEdit{status: :pending} = assigns.pending_edit
+ end
+
+ test "fetch_queue_counts hits the Cache.fetch_or_store round trip", %{conn: conn} do
+ alias Microwaveprop.Workers.SolarIndexWorker
+
+ {:ok, _job} = Oban.insert(SolarIndexWorker.new(%{"date" => "2026-03-28"}))
+
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ counts = :sys.get_state(lv.pid).socket.assigns.queue_counts
+ assert is_map(counts)
+ end
+
+ test "handle_info({:sounding_fetched, β¦}) with mismatched contact_id is a no-op",
+ %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ soundings_before = :sys.get_state(lv.pid).socket.assigns.soundings
+ send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}})
+ _ = :sys.get_state(lv.pid)
+
+ assert Process.alive?(lv.pid)
+ assert :sys.get_state(lv.pid).socket.assigns.soundings == soundings_before
+ end
+
+ test "rain_scatter mechanism surfaces for a 24 GHz contact with heavy rain pixels",
+ %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("24000"),
+ distance_km: Decimal.new("250")
+ })
+
+ Repo.insert!(%ContactCommonVolumeRadar{
+ contact_id: contact.id,
+ observed_at: contact.qso_timestamp,
+ common_volume_km2: 15_000.0,
+ pixel_count: 1200,
+ rain_pixel_count: 400,
+ heavy_rain_pixel_count: 120,
+ max_dbz: 52.0,
+ mean_dbz: 35.0,
+ coverage_pct: 33.0
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "52.0 dBZ"
+ assert html =~ "Heavy-rain pixels: 120"
+ assert html =~ "Propagation Mechanism"
+ end
+
+ test "elevation profile hydrates when the SRTM stub returns a populated path",
+ %{conn: conn} do
+ Req.Test.stub(ElevationClient, fn conn ->
+ if conn.host == "api.open-meteo.com" do
+ n = 257
+ Req.Test.json(conn, %{"elevation" => List.duplicate(300.0, n)})
+ else
+ Req.Test.json(conn, [])
+ end
+ end)
+
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
+ hpbl_m: 1200.0,
+ pwat_mm: 25.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 10.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -200.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile
+ assert ep
+ assert is_list(ep.points)
+ assert is_list(ep.ducts)
+ end
+
+ property "Scorer.composite_score always stays within [0, 100]" do
+ all_band_mhz = BandConfig.all_freqs()
+
+ check all(
+ band_mhz <- StreamData.member_of(all_band_mhz),
+ temp_c <- StreamData.float(min: -40.0, max: 50.0),
+ td_depression <- StreamData.float(min: 0.0, max: 25.0),
+ pressure_mb <- StreamData.float(min: 950.0, max: 1050.0),
+ sky_pct <- StreamData.float(min: 0.0, max: 100.0),
+ wind_kts <- StreamData.float(min: 0.0, max: 40.0),
+ grad <- StreamData.float(min: -300.0, max: 50.0),
+ pwat_mm <- StreamData.float(min: 0.0, max: 60.0),
+ month <- StreamData.integer(1..12),
+ hour <- StreamData.integer(0..23),
+ max_runs: 30
+ ) do
+ band_config = BandConfig.get(band_mhz)
+
+ conditions = %{
+ temp_f: temp_c * 9 / 5 + 32,
+ dewpoint_f: (temp_c - td_depression) * 9 / 5 + 32,
+ temp_c: temp_c,
+ dewpoint_c: temp_c - td_depression,
+ abs_humidity: max(0.1, 10.0 - td_depression * 0.3),
+ min_refractivity_gradient: grad,
+ bl_depth_m: 1000.0,
+ pressure_mb: pressure_mb,
+ prev_pressure_mb: pressure_mb,
+ sky_cover_pct: sky_pct,
+ wind_speed_kts: wind_kts,
+ rain_rate_mmhr: 0.0,
+ pwat_mm: pwat_mm,
+ month: month,
+ utc_hour: hour,
+ utc_minute: 0,
+ latitude: 32.0,
+ longitude: -97.0,
+ best_duct_band_ghz: nil,
+ bulk_richardson: nil
+ }
+
+ %{score: score} = Scorer.composite_score(conditions, band_config)
+ assert is_integer(score)
+ assert score >= 0 and score <= 100
+ end
+ end
+
+ property "obs-table sort handler round-trips for any known field",
+ %{conn: conn} do
+ contact = create_contact()
+
+ fields_pool =
+ StreamData.member_of(~w(observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb))
+
+ check all(field <- fields_pool, max_runs: 12) do
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"})
+
+ _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
+ a1 = :sys.get_state(lv.pid).socket.assigns
+ assert a1.obs_sort_by == field
+ assert a1.obs_sort_order == "asc"
+
+ _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
+ a2 = :sys.get_state(lv.pid).socket.assigns
+ assert a2.obs_sort_order == "desc"
+
+ _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
+ a3 = :sys.get_state(lv.pid).socket.assigns
+ assert a3.obs_sort_order == "asc"
+ end
+ end
+ end
+
+ describe "Show failure-state + enrichment-combo render branches" do
+ use ExUnitProperties
+
+ import Microwaveprop.AccountsFixtures
+
+ alias Microwaveprop.Propagation.ScoreCache
+ alias Microwaveprop.Radio.ContactCommonVolumeRadar
+ alias Microwaveprop.Terrain.TerrainProfile
+ alias Microwaveprop.Weather
+ alias Microwaveprop.Weather.HrrrProfile
+ alias Microwaveprop.Weather.SolarIndex
+ alias Microwaveprop.Weather.Sounding
+ alias Microwaveprop.Weather.SurfaceObservation
+
+ setup do
+ ScoreCache.clear()
+ :ok
+ end
+
+ test "terrain_status :failed with no terrain row falls back to the no-profile copy",
+ %{conn: conn} do
+ contact =
+ create_contact()
+ |> Ecto.Changeset.change(terrain_status: :failed)
+ |> Repo.update!()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "No terrain profile available."
+ refute html =~ "Computing terrain profile"
+ end
+
+ test "iemre_status :failed shows the generic 'No IEMRE data' copy", %{conn: conn} do
+ contact =
+ create_contact()
+ |> Ecto.Changeset.change(iemre_status: :failed)
+ |> Repo.update!()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "No IEMRE data available."
+ refute html =~ "Fetching IEMRE data"
+ end
+
+ test "weather_status :failed renders the empty surface-obs copy with no spinner",
+ %{conn: conn} do
+ contact =
+ create_contact()
+ |> Ecto.Changeset.change(weather_status: :failed)
+ |> Repo.update!()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "No surface observations found nearby."
+ end
+
+ test "hrrr_status :unavailable + no NARR row shows the NARR-fallback spinner",
+ %{conn: conn} do
+ contact =
+ create_contact()
+ |> Ecto.Changeset.change(hrrr_status: :unavailable)
+ |> Repo.update!()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "HRRR unavailable"
+ assert html =~ "fetching NARR reanalysis"
+ end
+
+ test "NARR fallback with HRRR nil renders atmospheric profile from NARR", %{conn: conn} do
+ contact =
+ create_contact(%{
+ qso_timestamp: ~U[2010-06-15 18:00:00Z],
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%NarrProfile{
+ valid_time: ~U[2010-06-15 18:00:00Z],
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 850.0, "tmpc" => 14.0, "dwpc" => 10.0, "hght" => 1500.0}],
+ surface_temp_c: 24.0,
+ surface_dewpoint_c: 18.0,
+ surface_pressure_mb: 1012.0,
+ hpbl_m: 1150.0,
+ pwat_mm: 28.0,
+ surface_refractivity: 318.0,
+ min_refractivity_gradient: -55.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "NARR"
+ assert html =~ "Surface Temp:"
+ assert html =~ "Surface Dewpoint:"
+ end
+
+ test "radar row without HRRR still renders the mechanism block and radar summary",
+ %{conn: conn} do
+ contact = create_contact(%{band: Decimal.new("10000"), distance_km: Decimal.new("150")})
+
+ Repo.insert!(%ContactCommonVolumeRadar{
+ contact_id: contact.id,
+ observed_at: contact.qso_timestamp,
+ common_volume_km2: 14_000.0,
+ pixel_count: 900,
+ rain_pixel_count: 60,
+ heavy_rain_pixel_count: 8,
+ max_dbz: 38.0,
+ mean_dbz: 19.0,
+ coverage_pct: 6.0
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Propagation Mechanism"
+ assert html =~ "38.0 dBZ"
+ assert html =~ "Heavy-rain pixels: 8"
+ assert html =~ "Radar coverage: 6%"
+ end
+
+ test "solar handle_async populates Solar Conditions with real SFI/Ap/Kp", %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+
+ Repo.insert!(%SolarIndex{
+ date: ~D[2026-03-28],
+ sfi: 142.0,
+ sunspot_number: 98,
+ ap_index: 6,
+ kp_values: [1.0, 2.0, 2.7, 3.0, 1.3, 0.7, 1.0, 1.3]
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Solar Conditions"
+ assert html =~ "142"
+ assert html =~ "98"
+ assert html =~ "2.7"
+ end
+
+ test "solar handle_async for a missing date in an internal subnet enqueues a fetch",
+ %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+ conn = %{conn | remote_ip: {172, 56, 0, 50}}
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ assigns = :sys.get_state(lv.pid).socket.assigns
+ assert assigns.solar == nil
+ assert Process.alive?(lv.pid)
+ end
+
+ test "soundings-array is rendered alongside HRRR profile when both are present",
+ %{conn: conn} do
+ contact =
+ create_contact(%{
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}],
+ hpbl_m: 1100.0,
+ pwat_mm: 24.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 12.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 322.0,
+ min_refractivity_gradient: -90.0,
+ ducting_detected: false
+ })
+
+ {:ok, raob_station} =
+ Weather.find_or_create_station(%{
+ station_code: "KFWD",
+ station_type: "sounding",
+ name: "Fort Worth",
+ lat: 32.83,
+ lon: -97.30
+ })
+
+ Repo.insert!(
+ Sounding.changeset(%Sounding{}, %{
+ station_id: raob_station.id,
+ observed_at: ~U[2026-03-28 12:00:00Z],
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 18.0, "hght" => 100.0},
+ %{"pres" => 950.0, "tmpc" => 24.0, "dwpc" => 5.0, "hght" => 600.0}
+ ],
+ level_count: 2,
+ surface_temp_c: 22.0,
+ surface_dewpoint_c: 18.0,
+ surface_refractivity: 345.0,
+ ducting_detected: true,
+ duct_characteristics: [%{"base" => 100.0, "top" => 400.0, "strength" => 8.0}]
+ })
+ )
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "HRRR"
+ assert html =~ "KFWD"
+ assert html =~ "345"
+ end
+
+ test "BLOCKED terrain without an elevation profile uses the generic obstructed copy",
+ %{conn: conn} do
+ contact =
+ create_contact(%{
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
+ hpbl_m: 1200.0,
+ pwat_mm: 22.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 10.0,
+ surface_pressure_mb: 1010.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -50.0,
+ ducting_detected: false
+ })
+
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
+ max_elevation_m: 800.0,
+ min_clearance_m: -10.0,
+ diffraction_db: 10.0,
+ fresnel_hit_count: 1,
+ obstructed_count: 1,
+ verdict: "BLOCKED"
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Path is terrain-obstructed"
+ end
+
+ test "FRESNEL_MINOR verdict via elevation profile drives the describe-verdict branch",
+ %{conn: conn} do
+ Req.Test.stub(ElevationClient, fn conn ->
+ if conn.host == "api.open-meteo.com" do
+ Req.Test.json(conn, %{"elevation" => List.duplicate(250.0, 257)})
+ else
+ Req.Test.json(conn, [])
+ end
+ end)
+
+ contact =
+ create_contact(%{
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 250.0}],
+ max_elevation_m: 260.0,
+ min_clearance_m: 30.0,
+ diffraction_db: 0.0,
+ fresnel_hit_count: 0,
+ obstructed_count: 0,
+ verdict: "CLEAR"
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile
+
+ if ep do
+ assert is_binary(ep.verdict)
+ end
+
+ assert render(lv) =~ "Propagation Mechanism"
+ end
+
+ test "2304 MHz band with elevated PWAT hits the 10 GHz band_summary branch",
+ %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("2304"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 18.0, "hght" => 100.0}],
+ hpbl_m: 1100.0,
+ pwat_mm: 32.0,
+ surface_temp_c: 22.0,
+ surface_dewpoint_c: 18.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 345.0,
+ min_refractivity_gradient: -90.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "At 10 GHz, the elevated moisture"
+ end
+
+ test "47 GHz band with elevated PWAT hits the 24G-and-up absorption copy",
+ %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("47000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}],
+ hpbl_m: 1200.0,
+ pwat_mm: 30.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 15.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 330.0,
+ min_refractivity_gradient: -60.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "high moisture"
+ assert html =~ "water vapor absorption"
+ end
+
+ test "144 MHz band falls through every band_summary clause (no moisture copy)",
+ %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("144"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 10.0, "dwpc" => -5.0, "hght" => 100.0}],
+ hpbl_m: 800.0,
+ pwat_mm: 8.0,
+ surface_temp_c: 10.0,
+ surface_dewpoint_c: -5.0,
+ surface_pressure_mb: 1015.0,
+ surface_refractivity: 290.0,
+ min_refractivity_gradient: -30.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+ refute html =~ "At 10 GHz, the elevated moisture"
+ refute html =~ "water vapor absorption"
+ assert html =~ "144 MHz"
+ end
+
+ test "1296 MHz band renders through the analysis without 10 GHz copy",
+ %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("1296"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 10.0, "dwpc" => 0.0, "hght" => 100.0}],
+ hpbl_m: 800.0,
+ pwat_mm: 10.0,
+ surface_temp_c: 10.0,
+ surface_dewpoint_c: 0.0,
+ surface_pressure_mb: 1015.0,
+ surface_refractivity: 300.0,
+ min_refractivity_gradient: -40.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "1296 MHz"
+ refute html =~ "At 10 GHz, the elevated moisture"
+ end
+
+ test "75 GHz + low PWAT skips the moisture narrative", %{conn: conn} do
+ contact =
+ create_contact(%{
+ band: Decimal.new("75000"),
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 5.0, "dwpc" => -15.0, "hght" => 100.0}],
+ hpbl_m: 700.0,
+ pwat_mm: 5.0,
+ surface_temp_c: 5.0,
+ surface_dewpoint_c: -15.0,
+ surface_pressure_mb: 1020.0,
+ surface_refractivity: 280.0,
+ min_refractivity_gradient: -30.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "75 GHz"
+ refute html =~ "water vapor absorption"
+ end
+
+ test "admin edit with proposed == %{} (no-op submit) surfaces 'No changes detected'",
+ %{conn: conn} do
+ contact = create_contact()
+ admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => contact.station1,
+ "station2" => contact.station2,
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => contact.mode,
+ "band" => Decimal.to_string(contact.band)
+ }
+ })
+
+ assert html =~ "No changes detected"
+ end
+
+ test "admin edit changing mode to FT8 applies the update", %{conn: conn} do
+ contact = create_contact(%{mode: "CW"})
+ admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => contact.station1,
+ "station2" => contact.station2,
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => "FT8"
+ }
+ })
+
+ assert html =~ "Contact updated"
+ assert Repo.get!(Contact, contact.id).mode == "FT8"
+ end
+
+ test "admin edit changing mode to SSB applies the update", %{conn: conn} do
+ contact = create_contact(%{mode: "CW"})
+ admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ _html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => contact.station1,
+ "station2" => contact.station2,
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => "SSB"
+ }
+ })
+
+ assert Repo.get!(Contact, contact.id).mode == "SSB"
+ end
+
+ test "sort handler returns a sorted list of the same length for a seeded obs set",
+ %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+
+ for code <- ~w(KDFW KDAL KAFW KGKY) do
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: code,
+ station_type: "asos",
+ name: "#{code} Airport",
+ lat: 32.9,
+ lon: -97.02
+ })
+
+ Repo.insert!(
+ SurfaceObservation.changeset(%SurfaceObservation{}, %{
+ station_id: station.id,
+ observed_at: contact.qso_timestamp,
+ temp_f: 60.0 + :rand.uniform() * 20,
+ dewpoint_f: 40.0 + :rand.uniform() * 20,
+ relative_humidity: 50.0,
+ sea_level_pressure_mb: 1014.0
+ })
+ )
+ end
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ before = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
+
+ for f <-
+ ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb bogus_key) do
+ html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
+ assert html =~ "Surface Observations"
+
+ after_count = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
+ assert after_count == before, "row count changed after sorting by #{f}"
+ end
+ end
+
+ test "fetch_queue_counts returns a map after seeding multiple distinct queues",
+ %{conn: conn} do
+ alias Microwaveprop.Workers.SolarIndexWorker
+ alias Microwaveprop.Workers.TerrainProfileWorker
+
+ contact = create_contact()
+
+ {:ok, _} =
+ Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(~D[2026-03-28])}))
+
+ {:ok, _} =
+ Oban.insert(TerrainProfileWorker.new(%{"contact_id" => contact.id}))
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ counts = :sys.get_state(lv.pid).socket.assigns.queue_counts
+
+ assert is_map(counts)
+
+ for {k, v} <- counts do
+ assert is_binary(k)
+ assert is_integer(v) and v >= 0
+ end
+ end
+
+ test "LV stays alive after render_async completes with no DB rows at all",
+ %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ assigns = :sys.get_state(lv.pid).socket.assigns
+ assert Process.alive?(lv.pid)
+ assert MapSet.size(assigns.hydration_pending) == 0
+ end
+
+ property "propagation_mechanism-derived summary stays a binary across random flag combos",
+ %{conn: conn} do
+ bool_gen = StreamData.boolean()
+
+ check all(
+ ducting? <- bool_gen,
+ enhanced? <- bool_gen,
+ blocked? <- bool_gen,
+ long_path? <- bool_gen,
+ max_runs: 10
+ ) do
+ dist = if long_path?, do: Decimal.new("200"), else: Decimal.new("50")
+
+ nonce = System.unique_integer([:positive])
+ lat1 = 32.0 + nonce / 1_000_000
+ iter_valid_time = DateTime.add(~U[2026-03-28 18:00:00Z], nonce, :second)
+
+ contact =
+ create_contact(%{
+ band: Decimal.new("10000"),
+ qso_timestamp: iter_valid_time,
+ pos1: %{"lat" => lat1, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: dist
+ })
+
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: lat1,
+ lon: -97.0,
+ profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}],
+ hpbl_m: 1200.0,
+ pwat_mm: 22.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 12.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: if(enhanced?, do: -150.0, else: -40.0),
+ ducting_detected: ducting?
+ })
+
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
+ max_elevation_m: if(blocked?, do: 800.0, else: 400.0),
+ min_clearance_m: if(blocked?, do: -20.0, else: 40.0),
+ diffraction_db: if(blocked?, do: 15.0, else: 0.0),
+ fresnel_hit_count: 0,
+ obstructed_count: if(blocked?, do: 2, else: 0),
+ verdict: if(blocked?, do: "BLOCKED", else: "CLEAR")
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ analysis = :sys.get_state(lv.pid).socket.assigns.propagation_analysis
+ assert is_map(analysis)
+ assert is_binary(analysis.summary)
+ assert is_list(analysis.details)
+ assert Enum.all?(analysis.details, &is_binary/1)
+ end
+ end
+
+ property "sort-observations handler keeps the assign as a list of stable length",
+ %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: "KDFW",
+ station_type: "asos",
+ name: "DFW Airport",
+ lat: 32.9,
+ lon: -97.02
+ })
+
+ for i <- 1..3 do
+ Repo.insert!(
+ SurfaceObservation.changeset(%SurfaceObservation{}, %{
+ station_id: station.id,
+ observed_at: DateTime.add(contact.qso_timestamp, i * 60, :second),
+ temp_f: 60.0 + i,
+ dewpoint_f: 45.0 + i,
+ relative_humidity: 55.0,
+ sea_level_pressure_mb: 1014.0
+ })
+ )
+ end
+
+ gen_field =
+ StreamData.one_of([
+ StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)),
+ StreamData.string(:alphanumeric, min_length: 1, max_length: 8)
+ ])
+
+ check all(fields <- StreamData.list_of(gen_field, min_length: 1, max_length: 5), max_runs: 10) do
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ baseline = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
+
+ for f <- fields do
+ render_click(lv, "sort", %{"field" => f, "table" => "obs"})
+ obs = :sys.get_state(lv.pid).socket.assigns.surface_observations
+ assert is_list(obs)
+ assert length(obs) == baseline, "sorting by #{inspect(f)} changed row count"
+ end
+ end
+ end
+ end
+
+ describe "Show ordering and proximity helpers" do
+ use ExUnitProperties
+
+ alias Microwaveprop.Geo
+ alias Microwaveprop.Weather
+ alias Microwaveprop.Weather.Sounding
+ alias Microwaveprop.Weather.SurfaceObservation
+
+ defp seed_distinct_obs(contact) do
+ Enum.map(
+ [
+ {"KAAA", 32.90, -97.00, 60.0, 40.0, 50.0, 1010.0, 0},
+ {"KBBB", 32.91, -97.01, 62.0, 42.0, 52.0, 1011.0, 60},
+ {"KCCC", 32.92, -97.02, 64.0, 44.0, 54.0, 1012.0, 120},
+ {"KDDD", 32.93, -97.03, 66.0, 46.0, 56.0, 1013.0, 180}
+ ],
+ fn {code, lat, lon, temp, dewp, rh, pres, offset} ->
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: code,
+ station_type: "asos",
+ name: "#{code} Airport",
+ lat: lat,
+ lon: lon
+ })
+
+ Repo.insert!(
+ SurfaceObservation.changeset(%SurfaceObservation{}, %{
+ station_id: station.id,
+ observed_at: DateTime.add(contact.qso_timestamp, offset, :second),
+ temp_f: temp,
+ dewpoint_f: dewp,
+ relative_humidity: rh,
+ sea_level_pressure_mb: pres
+ })
+ )
+
+ code
+ end
+ )
+ end
+
+ test "sort_observations orders by station_name ascending and descending", %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+ codes = seed_distinct_obs(contact)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ _ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
+
+ _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"})
+
+ asc_codes =
+ lv.pid
+ |> :sys.get_state()
+ |> Map.fetch!(:socket)
+ |> Map.fetch!(:assigns)
+ |> Map.fetch!(:surface_observations)
+ |> Enum.map(& &1.station.station_code)
+
+ assert asc_codes == Enum.sort(codes)
+
+ _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"})
+
+ desc_codes =
+ lv.pid
+ |> :sys.get_state()
+ |> Map.fetch!(:socket)
+ |> Map.fetch!(:assigns)
+ |> Map.fetch!(:surface_observations)
+ |> Enum.map(& &1.station.station_code)
+
+ assert desc_codes == Enum.sort(codes, :desc)
+ end
+
+ test "sort_observations orders by observed_at, temp_f, dewpoint_f, relative_humidity, and pressure",
+ %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+ _ = seed_distinct_obs(contact)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ check_field = fn field, reader ->
+ _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
+
+ obs =
+ lv.pid
+ |> :sys.get_state()
+ |> Map.fetch!(:socket)
+ |> Map.fetch!(:assigns)
+ |> Map.fetch!(:surface_observations)
+
+ values = Enum.map(obs, reader)
+
+ assert values == Enum.sort(values),
+ "obs not ascending on #{field}: #{inspect(values)}"
+ end
+
+ check_field.("observed_at", & &1.observed_at)
+ check_field.("temp_f", & &1.temp_f)
+ check_field.("dewpoint_f", & &1.dewpoint_f)
+ check_field.("relative_humidity", & &1.relative_humidity)
+ check_field.("sea_level_pressure_mb", & &1.sea_level_pressure_mb)
+ end
+
+ defp seed_distinct_soundings(contact) do
+ Enum.map(
+ [
+ {"KAAAS", 15.0, 310.0, 20.0, -2.0, 0},
+ {"KBBBS", 18.0, 315.0, 25.0, -1.0, 3600},
+ {"KCCCS", 21.0, 320.0, 30.0, 0.0, 7200}
+ ],
+ fn {code, temp, refr, k, li, offset} ->
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: code,
+ station_type: "sounding",
+ name: "#{code} Sounding",
+ lat: 32.9 + :rand.uniform() * 0.001,
+ lon: -97.0 + :rand.uniform() * 0.001
+ })
+
+ Repo.insert!(
+ Sounding.changeset(%Sounding{}, %{
+ station_id: station.id,
+ observed_at: DateTime.add(contact.qso_timestamp, offset, :second),
+ profile: [%{"pres" => 1000.0, "tmpc" => temp, "dwpc" => temp - 5.0, "hght" => 100.0}],
+ level_count: 1,
+ surface_temp_c: temp,
+ surface_dewpoint_c: temp - 5.0,
+ surface_refractivity: refr,
+ k_index: k,
+ lifted_index: li
+ })
+ )
+
+ code
+ end
+ )
+ end
+
+ test "sort_soundings orders by every known sounding field", %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+ _ = seed_distinct_soundings(contact)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ _ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"})
+ _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "soundings"})
+
+ station_names =
+ lv.pid
+ |> :sys.get_state()
+ |> Map.fetch!(:socket)
+ |> Map.fetch!(:assigns)
+ |> Map.fetch!(:soundings)
+ |> Enum.map(&(&1.station.name || &1.station.station_code))
+
+ assert station_names == Enum.sort(station_names)
+
+ check_field = fn field, reader ->
+ _ = render_click(lv, "sort", %{"field" => field, "table" => "soundings"})
+
+ rows =
+ lv.pid
+ |> :sys.get_state()
+ |> Map.fetch!(:socket)
+ |> Map.fetch!(:assigns)
+ |> Map.fetch!(:soundings)
+
+ values = Enum.map(rows, reader)
+
+ assert values == Enum.sort(values),
+ "soundings not ascending on #{field}: #{inspect(values)}"
+ end
+
+ check_field.("observed_at", & &1.observed_at)
+ check_field.("surface_temp_c", & &1.surface_temp_c)
+ check_field.("surface_refractivity", & &1.surface_refractivity)
+ check_field.("k_index", & &1.k_index)
+ check_field.("lifted_index", & &1.lifted_index)
+ end
+
+ test "closest_observations/4 keeps only the five nearest by midpoint distance", %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+ mid_lat = (32.9 + 30.3) / 2
+ mid_lon = (-97.0 + -97.7) / 2
+
+ near_to_far = [
+ {"KMID0", mid_lat, mid_lon},
+ {"KMID1", mid_lat + 0.05, mid_lon + 0.05},
+ {"KMID2", mid_lat - 0.05, mid_lon - 0.05},
+ {"KMID3", mid_lat + 0.10, mid_lon + 0.10},
+ {"KMID4", mid_lat - 0.10, mid_lon - 0.10},
+ {"KMID5", mid_lat + 0.20, mid_lon + 0.20},
+ {"KMID6", mid_lat - 0.20, mid_lon - 0.20}
+ ]
+
+ _ =
+ Enum.map(near_to_far, fn {code, lat, lon} ->
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: code,
+ station_type: "asos",
+ name: "#{code}",
+ lat: lat,
+ lon: lon
+ })
+
+ Repo.insert!(
+ SurfaceObservation.changeset(%SurfaceObservation{}, %{
+ station_id: station.id,
+ observed_at: contact.qso_timestamp,
+ temp_f: 70.0,
+ dewpoint_f: 55.0,
+ relative_humidity: 55.0,
+ sea_level_pressure_mb: 1015.0
+ })
+ )
+ end)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ obs =
+ lv.pid
+ |> :sys.get_state()
+ |> Map.fetch!(:socket)
+ |> Map.fetch!(:assigns)
+ |> Map.fetch!(:surface_observations)
+
+ assert length(obs) <= 5
+
+ distances =
+ Enum.map(obs, fn o ->
+ abs(o.station.lat - mid_lat) + abs(o.station.lon - mid_lon)
+ end)
+
+ assert distances == Enum.sort(distances)
+ end
+
+ test "contact with pos1 but nil pos2 renders without blowing up (half_dist = 0 path)",
+ %{conn: conn} do
+ contact = create_contact()
+
+ {:ok, contact} =
+ contact
+ |> Ecto.Changeset.change(pos2: nil, grid2: nil, distance_km: nil)
+ |> Repo.update()
+
+ assert is_nil(contact.pos2)
+
+ {:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ assert html =~ "W5XD"
+ assert Process.alive?(lv.pid)
+ end
+
+ property "sort_observations/3 preserves list length + element set for any field key",
+ %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+ codes = seed_distinct_obs(contact)
+
+ fields_gen =
+ StreamData.one_of([
+ StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)),
+ StreamData.string(:alphanumeric, min_length: 1, max_length: 6)
+ ])
+
+ check all(fields <- StreamData.list_of(fields_gen, min_length: 1, max_length: 5), max_runs: 10) do
+ {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ for f <- fields do
+ _ = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
+
+ obs =
+ lv.pid
+ |> :sys.get_state()
+ |> Map.fetch!(:socket)
+ |> Map.fetch!(:assigns)
+ |> Map.fetch!(:surface_observations)
+
+ assert length(obs) == length(codes)
+ assert Enum.sort(Enum.map(obs, & &1.station.station_code)) == Enum.sort(codes)
+ end
+ end
+ end
+
+ property "haversine_km/4 is symmetric: d(a,b,c,d) == d(c,d,a,b)" do
+ check all(
+ lat1 <- StreamData.float(min: -89.0, max: 89.0),
+ lon1 <- StreamData.float(min: -179.0, max: 179.0),
+ lat2 <- StreamData.float(min: -89.0, max: 89.0),
+ lon2 <- StreamData.float(min: -179.0, max: 179.0)
+ ) do
+ ab = Geo.haversine_km(lat1, lon1, lat2, lon2)
+ ba = Geo.haversine_km(lat2, lon2, lat1, lon1)
+ assert_in_delta ab, ba, 1.0e-6
+ end
+ end
+ end
+end
diff --git a/test/microwaveprop_web/live/contact_live/show_hydration_test.exs b/test/microwaveprop_web/live/contact_live/show_hydration_test.exs
new file mode 100644
index 00000000..b6b5ec55
--- /dev/null
+++ b/test/microwaveprop_web/live/contact_live/show_hydration_test.exs
@@ -0,0 +1,679 @@
+defmodule MicrowavepropWeb.ContactLive.ShowHydrationTest do
+ use MicrowavepropWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Microwaveprop.Radio.Contact
+ alias Microwaveprop.Repo
+ alias Microwaveprop.Terrain.ElevationClient
+ alias Microwaveprop.Weather.HrrrNativeProfile
+ alias Microwaveprop.Weather.NarrProfile
+
+ setup do
+ Req.Test.stub(ElevationClient, fn conn ->
+ Req.Test.json(conn, [])
+ end)
+
+ Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
+ Plug.Conn.send_resp(conn, 404, "not found")
+ end)
+
+ Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
+ Req.Test.text(conn, "")
+ end)
+
+ :ok
+ end
+
+ defp create_contact(attrs \\ %{}) do
+ default = %{
+ station1: "W5XD",
+ station2: "K5TR",
+ qso_timestamp: ~U[2026-03-28 18:00:00Z],
+ mode: "CW",
+ band: Decimal.new("1296"),
+ grid1: "EM12",
+ grid2: "EM00",
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("295")
+ }
+
+ merged = Map.merge(default, attrs)
+ {user_id, changeset_attrs} = Map.pop(merged, :user_id)
+
+ {:ok, contact} =
+ %Contact{user_id: user_id}
+ |> Contact.changeset(changeset_attrs)
+ |> Repo.insert()
+
+ contact
+ end
+
+ describe "Show fully-hydrated render paths" do
+ alias Microwaveprop.Propagation.ScoreCache
+ alias Microwaveprop.Radio.ContactCommonVolumeRadar
+ alias Microwaveprop.Terrain.TerrainProfile
+ alias Microwaveprop.Weather
+ alias Microwaveprop.Weather.HrrrProfile
+ alias Microwaveprop.Weather.IemreObservation
+ alias Microwaveprop.Weather.Sounding
+ alias Microwaveprop.Weather.SurfaceObservation
+
+ defp seed_fully_hydrated do
+ contact =
+ create_contact(%{
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ _ =
+ Repo.insert!(%HrrrProfile{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
+ %{"pres" => 925.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 800.0},
+ %{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
+ ],
+ hpbl_m: 1200.0,
+ pwat_mm: 25.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 12.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -120.0,
+ ducting_detected: false
+ })
+
+ _ =
+ Repo.insert!(%TerrainProfile{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"km" => 0.0, "elev_m" => 200.0}, %{"km" => 295.0, "elev_m" => 250.0}],
+ max_elevation_m: 450.0,
+ min_clearance_m: 20.0,
+ diffraction_db: 6.5,
+ fresnel_hit_count: 3,
+ obstructed_count: 1,
+ verdict: "CLEAR"
+ })
+
+ _ =
+ Repo.insert!(%ContactCommonVolumeRadar{
+ contact_id: contact.id,
+ observed_at: contact.qso_timestamp,
+ common_volume_km2: 12_000.0,
+ pixel_count: 500,
+ rain_pixel_count: 5,
+ heavy_rain_pixel_count: 0,
+ max_dbz: 22.0,
+ mean_dbz: 12.0,
+ coverage_pct: 1.0
+ })
+
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: "KDFW",
+ station_type: "asos",
+ name: "DFW Airport",
+ lat: 32.9,
+ lon: -97.02
+ })
+
+ Repo.insert!(
+ SurfaceObservation.changeset(%SurfaceObservation{}, %{
+ station_id: station.id,
+ observed_at: contact.qso_timestamp,
+ temp_f: 72.0,
+ dewpoint_f: 55.0,
+ relative_humidity: 55.0,
+ sea_level_pressure_mb: 1015.0
+ })
+ )
+
+ {:ok, raob_station} =
+ Weather.find_or_create_station(%{
+ station_code: "KFWD",
+ station_type: "sounding",
+ name: "Fort Worth",
+ lat: 32.83,
+ lon: -97.30
+ })
+
+ Repo.insert!(
+ Sounding.changeset(%Sounding{}, %{
+ station_id: raob_station.id,
+ observed_at: ~U[2026-03-28 12:00:00Z],
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
+ %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
+ ],
+ level_count: 2,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 15.0
+ })
+ )
+
+ _ =
+ Repo.insert!(%IemreObservation{
+ date: ~D[2026-03-28],
+ lat: 31.625,
+ lon: -97.375,
+ hourly: [
+ %{"utc_hour" => 18, "air_temp_f" => 70.0, "dew_point_f" => 55.0, "skyc_pct" => 30.0}
+ ]
+ })
+
+ contact
+ end
+
+ setup do
+ ScoreCache.clear()
+ :ok
+ end
+
+ test "fully populated contact renders every enrichment section", %{conn: conn} do
+ contact = seed_fully_hydrated()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ contact.station1
+ assert html =~ "Atmospheric Profile"
+ assert html =~ "Surface Observations"
+ assert html =~ "Soundings"
+ assert html =~ "1200"
+ assert html =~ "Line of sight is clear"
+ end
+
+ test "handle_info :terrain_ready refreshes the terrain section", %{conn: conn} do
+ contact = seed_fully_hydrated()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ send(lv.pid, {:terrain_ready, contact.id})
+ html = render(lv)
+ assert html =~ contact.station1
+ end
+
+ test "handle_info ignores terrain_ready for unrelated contact", %{conn: conn} do
+ contact = seed_fully_hydrated()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ send(lv.pid, {:terrain_ready, Ecto.UUID.generate()})
+ assert Process.alive?(lv.pid)
+ end
+
+ test "handle_info :sounding_fetched rehydrates the soundings table", %{conn: conn} do
+ contact = seed_fully_hydrated()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ send(lv.pid, {:sounding_fetched, %{contact_id: contact.id}})
+ html = render(lv)
+ assert html =~ "Soundings"
+ end
+
+ test "handle_info :sounding_fetched for a different contact is a no-op", %{conn: conn} do
+ contact = seed_fully_hydrated()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}})
+ assert Process.alive?(lv.pid)
+ end
+
+ test "flagged_invalid contact shows the Flagged invalid badge", %{conn: conn} do
+ contact = create_contact()
+
+ {:ok, contact} =
+ contact
+ |> Ecto.Changeset.change(flagged_invalid: true)
+ |> Repo.update()
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "Flagged invalid"
+ assert html =~ "line-through"
+ end
+
+ test "owner sees an Edit button (not Suggest Edit) for their own contact", %{conn: conn} do
+ import Microwaveprop.AccountsFixtures
+
+ owner = user_fixture()
+
+ {:ok, contact} =
+ %Contact{}
+ |> Contact.changeset(%{
+ station1: owner.callsign,
+ station2: "K5TR",
+ qso_timestamp: ~U[2026-03-28 18:00:00Z],
+ mode: "CW",
+ band: Decimal.new("1296"),
+ grid1: "EM12",
+ grid2: "EM00",
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("295"),
+ user_submitted: true,
+ submitter_email: owner.email
+ })
+ |> Ecto.Changeset.change(user_id: owner.id)
+ |> Repo.insert()
+
+ conn = log_in_user(conn, owner)
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ assert html =~ ">\n Edit\n " or html =~ "Edit"
+ refute html =~ "Suggest Edit"
+ end
+ end
+
+ describe "Show render-branch coverage" do
+ use ExUnitProperties
+
+ alias Microwaveprop.Propagation.ScoreCache
+ alias Microwaveprop.Radio.ContactCommonVolumeRadar
+ alias Microwaveprop.Radio.ContactEdit
+ alias Microwaveprop.Terrain.TerrainProfile
+ alias Microwaveprop.Weather
+ alias Microwaveprop.Weather.HrrrProfile
+ alias Microwaveprop.Weather.IemreObservation
+ alias Microwaveprop.Weather.Sounding
+ alias Microwaveprop.Weather.SurfaceObservation
+
+ defp seed_contact_with_overrides(opts \\ []) do
+ contact_overrides = Keyword.get(opts, :contact, %{})
+ terrain_overrides = Keyword.get(opts, :terrain, %{})
+ hrrr_overrides = Keyword.get(opts, :hrrr, %{})
+
+ contact =
+ create_contact(
+ Map.merge(
+ %{
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ },
+ contact_overrides
+ )
+ )
+
+ hrrr_row =
+ Map.merge(
+ %{
+ valid_time: contact.qso_timestamp,
+ lat: 32.9,
+ lon: -97.0,
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
+ %{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
+ ],
+ hpbl_m: 1200.0,
+ pwat_mm: 25.0,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 12.0,
+ surface_pressure_mb: 1012.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -120.0,
+ ducting_detected: false
+ },
+ hrrr_overrides
+ )
+
+ Repo.insert!(struct!(HrrrProfile, hrrr_row))
+
+ terrain_row =
+ Map.merge(
+ %{
+ contact_id: contact.id,
+ sample_count: 100,
+ path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
+ max_elevation_m: 450.0,
+ min_clearance_m: 20.0,
+ diffraction_db: 6.5,
+ fresnel_hit_count: 0,
+ obstructed_count: 0,
+ verdict: "CLEAR"
+ },
+ terrain_overrides
+ )
+
+ Repo.insert!(struct!(TerrainProfile, terrain_row))
+
+ contact
+ end
+
+ setup do
+ ScoreCache.clear()
+ :ok
+ end
+
+ test "IEMRE section renders nearest-hour temperature and dew point", %{conn: conn} do
+ contact = create_contact()
+
+ Repo.insert!(%IemreObservation{
+ date: DateTime.to_date(contact.qso_timestamp),
+ lat: 32.875,
+ lon: -97.0,
+ hourly: [
+ %{
+ "utc_hour" => 18,
+ "air_temp_f" => 73.4,
+ "dew_point_f" => 54.3,
+ "skyc_%" => 42.0,
+ "uwnd_mps" => 3.0,
+ "vwnd_mps" => 4.0,
+ "hourly_precip_in" => 0.0
+ }
+ ]
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "IEMRE Gridded Reanalysis"
+ assert html =~ "73.4"
+ assert html =~ "54.3"
+ end
+
+ test "IEMRE section shows 'unavailable' status when marked as such", %{conn: conn} do
+ contact =
+ create_contact()
+ |> Ecto.Changeset.change(iemre_status: :unavailable)
+ |> Repo.update!()
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "IEMRE data unavailable for this contact."
+ end
+
+ test "BLOCKED terrain verdict shows the obstruction badge class", %{conn: conn} do
+ contact =
+ seed_contact_with_overrides(
+ terrain: %{
+ verdict: "BLOCKED",
+ obstructed_count: 4,
+ fresnel_hit_count: 2,
+ diffraction_db: 18.2
+ }
+ )
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "BLOCKED"
+ assert html =~ "badge-error"
+ assert html =~ "obstructed"
+ end
+
+ test "FRESNEL_MINOR terrain verdict renders with warning styling", %{conn: conn} do
+ contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_MINOR"})
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "FRESNEL_MINOR"
+ assert html =~ "badge-warning"
+ end
+
+ test "FRESNEL_PARTIAL terrain verdict renders with warning styling", %{conn: conn} do
+ contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_PARTIAL"})
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "FRESNEL_PARTIAL"
+ assert html =~ "badge-warning"
+ end
+
+ test "expanded terrain table renders all path points", %{conn: conn} do
+ contact =
+ seed_contact_with_overrides(
+ terrain: %{
+ verdict: "CLEAR",
+ path_points: [
+ %{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0},
+ %{"lat" => 31.6, "lon" => -97.35, "dist_km" => 147.5, "elev" => 320.0},
+ %{"lat" => 30.3, "lon" => -97.7, "dist_km" => 295.0, "elev" => 250.0}
+ ]
+ }
+ )
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ html = render_click(lv, "toggle_terrain", %{})
+ assert html =~ "Dist (km)"
+ end
+
+ test "Propagation Analysis renders factor rows and composite tier label", %{conn: conn} do
+ contact = seed_contact_with_overrides()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Propagation Analysis"
+ assert html =~ "Contribution"
+ assert html =~ "Humidity"
+ assert html =~ "/100"
+
+ assert html =~ "EXCELLENT" or html =~ "GOOD" or html =~ "MARGINAL" or
+ html =~ "POOR" or html =~ "NEGLIGIBLE"
+ end
+
+ test "Data Sources card renders HRRR, Terrain, and Elevation blocks", %{conn: conn} do
+ contact = seed_contact_with_overrides()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Data Sources"
+ assert html =~ "HRRR Model"
+ assert html =~ "Terrain Analysis"
+ assert html =~ "Elevation Profile"
+ end
+
+ test "internal-network conn kicks off enqueue path and transitions pending status", %{conn: conn} do
+ contact = create_contact()
+
+ conn = %{conn | remote_ip: {172, 56, 0, 1}}
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ assert Process.alive?(lv.pid)
+ assert render(lv) =~ contact.station1
+ end
+
+ test "radar row surfaces max_dbz and coverage_pct in the mechanism block", %{conn: conn} do
+ contact = create_contact()
+
+ Repo.insert!(%ContactCommonVolumeRadar{
+ contact_id: contact.id,
+ observed_at: contact.qso_timestamp,
+ common_volume_km2: 12_500.0,
+ pixel_count: 800,
+ rain_pixel_count: 150,
+ heavy_rain_pixel_count: 25,
+ max_dbz: 47.5,
+ mean_dbz: 22.3,
+ coverage_pct: 18.0
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Propagation Mechanism"
+ assert html =~ "47.5 dBZ"
+ assert html =~ "Heavy-rain pixels: 25"
+ end
+
+ test "non-owner with a pending edit sees the pending-edit banner", %{conn: conn} do
+ import Microwaveprop.AccountsFixtures
+
+ contact = create_contact()
+ stranger = user_fixture()
+
+ Repo.insert!(%ContactEdit{
+ contact_id: contact.id,
+ user_id: stranger.id,
+ proposed_changes: %{"station2" => "K9EDIT"},
+ status: :pending
+ })
+
+ conn = log_in_user(conn, stranger)
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ assert html =~ "pending edit for this contact awaiting admin review"
+ end
+
+ test "full Atmospheric Profile block renders surface temp/dewpoint/pressure", %{conn: conn} do
+ contact = seed_contact_with_overrides()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Surface Temp:"
+ assert html =~ "Surface Dewpoint:"
+ assert html =~ "Surface Pressure:"
+ end
+
+ test "soundings-empty state shows the 1000 km fallback copy", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "No soundings found within 1000 km."
+ end
+
+ test "surface-observations queued status shows spinner copy", %{conn: conn} do
+ contact =
+ create_contact()
+ |> Ecto.Changeset.change(weather_status: :queued)
+ |> Repo.update!()
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ assert html =~ "Fetching surface observations" or html =~ "Surface Observations"
+ end
+
+ test "ducting detected on the HRRR profile shows the Ducting badge", %{conn: conn} do
+ contact =
+ seed_contact_with_overrides(
+ hrrr: %{
+ ducting_detected: true,
+ min_refractivity_gradient: -200.0
+ }
+ )
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "Ducting"
+ end
+
+ test "sounding row with a real station shows the ducting detection badge", %{conn: conn} do
+ contact = create_contact()
+
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: "KFWD",
+ station_type: "sounding",
+ name: "Fort Worth",
+ lat: 32.83,
+ lon: -97.30
+ })
+
+ Repo.insert!(
+ Sounding.changeset(%Sounding{}, %{
+ station_id: station.id,
+ observed_at: ~U[2026-03-28 12:00:00Z],
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
+ %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
+ ],
+ level_count: 2,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 15.0,
+ surface_refractivity: 340.0,
+ ducting_detected: true
+ })
+ )
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "KFWD"
+ assert html =~ "Ducting"
+ end
+
+ property "obs-table sort handler is total over every known field and direction", %{conn: conn} do
+ contact = create_contact(%{station1: "W5PROP", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: "KDFW",
+ station_type: "asos",
+ name: "DFW Airport",
+ lat: 32.9,
+ lon: -97.02
+ })
+
+ Repo.insert!(
+ SurfaceObservation.changeset(%SurfaceObservation{}, %{
+ station_id: station.id,
+ observed_at: contact.qso_timestamp,
+ temp_f: 70.0,
+ dewpoint_f: 55.0,
+ relative_humidity: 55.0,
+ sea_level_pressure_mb: 1015.0
+ })
+ )
+
+ known_fields =
+ ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb zzz_unknown)
+
+ field_stream = StreamData.member_of(known_fields)
+ sequence_gen = StreamData.list_of(field_stream, min_length: 1, max_length: 6)
+
+ check all(fields <- sequence_gen, max_runs: 15) do
+ {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ for f <- fields do
+ html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
+ assert html =~ "Surface Observations", "sort by #{f} lost the obs table"
+ end
+
+ assert Process.alive?(lv.pid)
+ end
+ end
+
+ property "soundings-table sort handler tolerates arbitrary field keys", %{conn: conn} do
+ contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
+
+ fields_pool =
+ ~w(station_name observed_at surface_temp_c surface_refractivity k_index lifted_index unknown)
+
+ sequence_gen =
+ StreamData.list_of(StreamData.member_of(fields_pool), min_length: 1, max_length: 4)
+
+ check all(fields <- sequence_gen, max_runs: 10) do
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ _ = render_async(lv, 2_000)
+
+ for f <- fields do
+ html = render_click(lv, "sort", %{"field" => f, "table" => "soundings"})
+ assert html =~ "Soundings", "sort by #{f} lost the soundings section"
+ end
+
+ assert Process.alive?(lv.pid)
+ end
+ end
+ end
+end
diff --git a/test/microwaveprop_web/live/contact_live/show_test.exs b/test/microwaveprop_web/live/contact_live/show_test.exs
new file mode 100644
index 00000000..dad9ef9c
--- /dev/null
+++ b/test/microwaveprop_web/live/contact_live/show_test.exs
@@ -0,0 +1,568 @@
+defmodule MicrowavepropWeb.ContactLive.ShowTest do
+ use MicrowavepropWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias Microwaveprop.Radio.Contact
+ alias Microwaveprop.Repo
+ alias Microwaveprop.Terrain.ElevationClient
+ alias Microwaveprop.Weather.HrrrNativeProfile
+ alias Microwaveprop.Weather.NarrProfile
+
+ setup do
+ Req.Test.stub(ElevationClient, fn conn ->
+ Req.Test.json(conn, [])
+ end)
+
+ Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
+ Plug.Conn.send_resp(conn, 404, "not found")
+ end)
+
+ Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
+ Req.Test.text(conn, "")
+ end)
+
+ :ok
+ end
+
+ defp create_contact(attrs \\ %{}) do
+ default = %{
+ station1: "W5XD",
+ station2: "K5TR",
+ qso_timestamp: ~U[2026-03-28 18:00:00Z],
+ mode: "CW",
+ band: Decimal.new("1296"),
+ grid1: "EM12",
+ grid2: "EM00",
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("295")
+ }
+
+ merged = Map.merge(default, attrs)
+ {user_id, changeset_attrs} = Map.pop(merged, :user_id)
+
+ {:ok, contact} =
+ %Contact{user_id: user_id}
+ |> Contact.changeset(changeset_attrs)
+ |> Repo.insert()
+
+ contact
+ end
+
+ describe "Show private visibility" do
+ import Microwaveprop.AccountsFixtures
+
+ test "anonymous viewer 404s on a private contact", %{conn: conn} do
+ contact = create_contact(%{private: true})
+
+ assert_raise Ecto.NoResultsError, fn ->
+ live(conn, ~p"/contacts/#{contact.id}")
+ end
+ end
+
+ test "non-owning user 404s on a private contact", %{conn: conn} do
+ owner = user_fixture()
+ other = user_fixture()
+ contact = create_contact(%{private: true, user_id: owner.id})
+ conn = log_in_user(conn, other)
+
+ assert_raise Ecto.NoResultsError, fn ->
+ live(conn, ~p"/contacts/#{contact.id}")
+ end
+ end
+
+ test "owner can view their own private contact", %{conn: conn} do
+ owner = user_fixture()
+ contact = create_contact(%{private: true, user_id: owner.id})
+ conn = log_in_user(conn, owner)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "W5XD"
+ end
+
+ test "admin can view anyone's private contact", %{conn: conn} do
+ owner = user_fixture()
+ admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
+ contact = create_contact(%{private: true, user_id: owner.id})
+ conn = log_in_user(conn, admin)
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "W5XD"
+ end
+ end
+
+ describe "Show" do
+ test "renders contact detail fields", %{conn: conn} do
+ contact = create_contact()
+
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "W5XD"
+ assert html =~ "K5TR"
+ assert html =~ "2026-03-28"
+ end
+
+ test "shows surface observations section", %{conn: conn} do
+ contact = create_contact()
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "Surface Observations"
+ end
+
+ test "shows sounding section", %{conn: conn} do
+ contact = create_contact()
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "Soundings"
+ end
+
+ test "shows solar index section", %{conn: conn} do
+ contact = create_contact()
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "Solar Conditions"
+ end
+
+ test "renders with sort assigns", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ assert html =~ "Surface Observations"
+ assert html =~ "Soundings"
+
+ assert render(lv) =~ contact.station1
+ end
+
+ test "renders sub-10-GHz bands in MHz", %{conn: conn} do
+ contact = create_contact(%{band: Decimal.new("432")})
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "432 MHz"
+ end
+
+ test "renders 50 MHz in MHz", %{conn: conn} do
+ contact = create_contact(%{band: Decimal.new("50")})
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "50 MHz"
+ end
+
+ test "renders 1296 MHz in MHz", %{conn: conn} do
+ contact = create_contact(%{band: Decimal.new("1296")})
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "1296 MHz"
+ refute html =~ "1.3 GHz"
+ end
+
+ test "renders 2304 MHz in MHz", %{conn: conn} do
+ contact = create_contact(%{band: Decimal.new("2304")})
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "2304 MHz"
+ refute html =~ "2.3 GHz"
+ end
+
+ test "renders 10 GHz in GHz (threshold)", %{conn: conn} do
+ contact = create_contact(%{band: Decimal.new("10000")})
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "10 GHz"
+ end
+
+ test "renders 24 GHz in GHz", %{conn: conn} do
+ contact = create_contact(%{band: Decimal.new("24000")})
+ {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
+ assert html =~ "24 GHz"
+ end
+
+ test "raises for bad UUID", %{conn: conn} do
+ assert_raise Ecto.NoResultsError, fn ->
+ live(conn, ~p"/contacts/#{Ecto.UUID.generate()}")
+ end
+ end
+
+ test "shows atmospheric profile section with NARR source for pre-HRRR contact", %{conn: conn} do
+ contact =
+ create_contact(%{
+ qso_timestamp: ~U[2010-06-15 18:00:00Z],
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ {:ok, _} =
+ Repo.insert(%NarrProfile{
+ valid_time: ~U[2010-06-15 18:00:00Z],
+ lat: 33.0,
+ lon: -97.0,
+ profile: [
+ %{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0}
+ ],
+ surface_temp_c: 25.0,
+ surface_dewpoint_c: 18.0,
+ surface_pressure_mb: 1012.0,
+ hpbl_m: 1200.0,
+ pwat_mm: 30.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -50.0,
+ ducting_detected: false
+ })
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ html = render_async(lv, 2_000)
+ assert html =~ "Atmospheric Profile"
+ assert html =~ "NARR"
+ assert html =~ "1200"
+ assert html =~ "33.0"
+ end
+
+ test "skew-T uses native HRRR profile when backfilled", %{conn: conn} do
+ contact =
+ create_contact(%{
+ qso_timestamp: ~U[2024-06-15 18:00:00Z],
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7}
+ })
+
+ {:ok, _} =
+ Repo.insert(%NarrProfile{
+ valid_time: ~U[2024-06-15 18:00:00Z],
+ lat: 32.9,
+ lon: -97.0,
+ profile: [%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0}],
+ surface_temp_c: 25.0,
+ surface_dewpoint_c: 18.0,
+ surface_pressure_mb: 1012.0,
+ hpbl_m: 1200.0,
+ pwat_mm: 30.0,
+ surface_refractivity: 320.0,
+ min_refractivity_gradient: -50.0,
+ ducting_detected: false
+ })
+
+ {:ok, _} =
+ %HrrrNativeProfile{}
+ |> HrrrNativeProfile.changeset(%{
+ valid_time: ~U[2024-06-15 18:00:00Z],
+ run_time: ~U[2024-06-15 18:00:00Z],
+ lat: 32.9,
+ lon: -97.0,
+ level_count: 3,
+ heights_m: [10.0, 1500.0, 9500.0],
+ temp_k: [298.0, 288.0, 228.0],
+ spfh: [0.012, 0.006, 0.0001],
+ pressure_pa: [101_000.0, 85_000.0, 30_000.0],
+ u_wind_ms: [2.0, 5.0, 20.0],
+ v_wind_ms: [1.0, 2.0, 5.0],
+ tke_m2s2: [0.5, 0.2, 0.05]
+ })
+ |> Repo.insert()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ html = render_async(lv, 2_000)
+
+ assert html =~ "HRRR native"
+ assert html =~ "300.0"
+ end
+ end
+
+ describe "Show interactive events" do
+ import Microwaveprop.AccountsFixtures
+
+ defp admin_fixture do
+ user = user_fixture()
+ {:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true})
+ user
+ end
+
+ test "toggle_hrrr_profile flips the atmospheric-profile collapse state", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ before_html = render(lv)
+ toggled = render_click(lv, "toggle_hrrr_profile", %{})
+ back = render_click(lv, "toggle_hrrr_profile", %{})
+
+ assert is_binary(toggled)
+ assert is_binary(back)
+ assert back =~ contact.station1 and before_html =~ contact.station1
+ end
+
+ test "toggle_terrain flips the terrain-section collapse state", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ html = render_click(lv, "toggle_terrain", %{})
+ assert is_binary(html)
+ end
+
+ test "toggle_profile (sounding expansion) tracks an id in the MapSet", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ html = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
+ assert is_binary(html)
+
+ html2 = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
+ assert is_binary(html2)
+ end
+
+ test "sort obs table by date toggles sort direction", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
+ html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
+ assert html =~ "Surface Observations"
+ end
+
+ test "sort obs table exercises every known field sort key", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ for field <- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb unknown_field) do
+ html = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
+ assert html =~ "Surface Observations", "sort by #{field} lost the obs table"
+ end
+ end
+
+ test "sort soundings table exercises every known field sort key", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ for field <- ~w(station_name observed_at unknown_field) do
+ html = render_click(lv, "sort", %{"field" => field, "table" => "soundings"})
+ assert html =~ "Soundings", "sort by #{field} lost the soundings table"
+ end
+ end
+
+ test "sort soundings table by date keeps the view rendering", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"})
+ assert html =~ "Soundings"
+ end
+
+ test "toggle_flag is denied to anonymous viewers (flash + no change)", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ html = render_click(lv, "toggle_flag", %{})
+ assert html =~ "Admins only"
+
+ refute Repo.get!(Contact, contact.id).flagged_invalid
+ end
+
+ test "toggle_flag inverts the flag for admins", %{conn: conn} do
+ contact = create_contact()
+ admin = admin_fixture()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_flag", %{})
+
+ refreshed = Repo.get!(Contact, contact.id)
+ assert refreshed.flagged_invalid == true
+
+ render_click(lv, "toggle_flag", %{})
+ assert Repo.get!(Contact, contact.id).flagged_invalid == false
+ end
+
+ test "toggle_edit opens and closes the inline edit form", %{conn: conn} do
+ contact = create_contact()
+ admin = admin_fixture()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, html_before} = live(conn, ~p"/contacts/#{contact.id}")
+ refute html_before =~ ~s(phx-submit="submit_edit")
+
+ html_open = render_click(lv, "toggle_edit", %{})
+ assert html_open =~ ~s(phx-submit="submit_edit")
+
+ html_closed = render_click(lv, "toggle_edit", %{})
+ refute html_closed =~ ~s(phx-submit="submit_edit")
+ end
+
+ test "submit_edit by a non-logged-in user flashes a login-required error", %{conn: conn} do
+ contact = create_contact()
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{"station1" => "N5XX"}
+ })
+
+ assert html =~ "logged in to edit"
+ end
+
+ test "submit_edit as admin applies a simple callsign change", %{conn: conn} do
+ contact = create_contact(%{station1: "N5OLD"})
+ admin = admin_fixture()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => "N5NEW",
+ "station2" => contact.station2,
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => contact.mode
+ }
+ })
+
+ assert html =~ "Contact updated"
+ assert Repo.get!(Contact, contact.id).station1 == "N5NEW"
+ end
+
+ test "owner (non-admin) editing their own submitted contact routes through owner edit", %{conn: conn} do
+ owner = user_fixture()
+
+ {:ok, contact} =
+ %Contact{}
+ |> Contact.changeset(%{
+ station1: owner.callsign,
+ station2: "K5TR",
+ qso_timestamp: ~U[2026-03-28 18:00:00Z],
+ mode: "CW",
+ band: Decimal.new("1296"),
+ grid1: "EM12",
+ grid2: "EM00",
+ pos1: %{"lat" => 32.9, "lon" => -97.0},
+ pos2: %{"lat" => 30.3, "lon" => -97.7},
+ distance_km: Decimal.new("295"),
+ user_submitted: true,
+ submitter_email: owner.email
+ })
+ |> Ecto.Changeset.change(user_id: owner.id)
+ |> Repo.insert()
+
+ conn = log_in_user(conn, owner)
+
+ {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => owner.callsign,
+ "station2" => "K9NEW",
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => contact.mode
+ }
+ })
+
+ assert html =~ "Contact updated"
+ assert Repo.get!(Contact, contact.id).station2 == "K9NEW"
+ end
+
+ test "non-owner non-admin submitting an edit creates a pending ContactEdit", %{conn: conn} do
+ contact = create_contact()
+ stranger = user_fixture()
+ conn = log_in_user(conn, stranger)
+
+ {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_submit(lv, "submit_edit", %{
+ "edit" => %{
+ "station1" => contact.station1,
+ "station2" => "K9SUGGEST",
+ "grid1" => contact.grid1,
+ "grid2" => contact.grid2,
+ "mode" => contact.mode
+ }
+ })
+
+ assert Repo.get!(Contact, contact.id).station2 == contact.station2
+
+ assert Repo.aggregate(Microwaveprop.Radio.ContactEdit, :count, :id) == 1
+ assert html =~ "submitted" or html =~ "suggest" or html =~ "pending"
+ end
+
+ test "renders the contact detail with a real surface observation row", %{conn: conn} do
+ alias Microwaveprop.Weather
+ alias Microwaveprop.Weather.SurfaceObservation
+
+ contact = create_contact()
+
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: "KDFW",
+ station_type: "asos",
+ name: "DFW Airport",
+ lat: 32.90,
+ lon: -97.02
+ })
+
+ {:ok, _} =
+ %SurfaceObservation{}
+ |> SurfaceObservation.changeset(%{
+ station_id: station.id,
+ observed_at: ~U[2026-03-28 18:00:00Z],
+ temp_f: 72.0,
+ dewpoint_f: 55.0,
+ relative_humidity: 55.0,
+ sea_level_pressure_mb: 1015.0
+ })
+ |> Repo.insert()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ html = render_async(lv, 2_000)
+ assert html =~ "Surface Observations"
+ end
+
+ test "renders the contact detail with a real sounding row", %{conn: conn} do
+ alias Microwaveprop.Weather
+ alias Microwaveprop.Weather.Sounding
+
+ contact = create_contact()
+
+ {:ok, station} =
+ Weather.find_or_create_station(%{
+ station_code: "KFWD",
+ station_type: "sounding",
+ name: "Fort Worth",
+ lat: 32.83,
+ lon: -97.30
+ })
+
+ {:ok, _} =
+ %Sounding{}
+ |> Sounding.changeset(%{
+ station_id: station.id,
+ observed_at: ~U[2026-03-28 12:00:00Z],
+ profile: [
+ %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
+ %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
+ ],
+ level_count: 2,
+ surface_temp_c: 20.0,
+ surface_dewpoint_c: 15.0
+ })
+ |> Repo.insert()
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+
+ html = render_async(lv, 2_000)
+ assert html =~ "KFWD"
+ end
+
+ test "validate_edit always returns :noreply without touching the DB", %{conn: conn} do
+ contact = create_contact()
+ admin = admin_fixture()
+ conn = log_in_user(conn, admin)
+
+ {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
+ render_click(lv, "toggle_edit", %{})
+
+ html =
+ render_change(lv, "validate_edit", %{
+ "edit" => %{"station1" => "N5TYPED"}
+ })
+
+ assert html =~ ~s(phx-submit="submit_edit")
+ assert Repo.get!(Contact, contact.id).station1 == "W5XD"
+ end
+ end
+end
diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs
deleted file mode 100644
index af776b7e..00000000
--- a/test/microwaveprop_web/live/contact_live_test.exs
+++ /dev/null
@@ -1,3505 +0,0 @@
-defmodule MicrowavepropWeb.ContactLiveTest do
- use MicrowavepropWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
-
- alias Microwaveprop.Radio.Contact
- alias Microwaveprop.Repo
- alias Microwaveprop.Terrain.ElevationClient
- alias Microwaveprop.Weather.HrrrNativeProfile
- alias Microwaveprop.Weather.NarrProfile
-
- setup do
- Req.Test.stub(ElevationClient, fn conn ->
- Req.Test.json(conn, [])
- end)
-
- Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
- Plug.Conn.send_resp(conn, 404, "not found")
- end)
-
- Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
- Req.Test.text(conn, "")
- end)
-
- :ok
- end
-
- defp create_contact(attrs \\ %{}) do
- default = %{
- station1: "W5XD",
- station2: "K5TR",
- qso_timestamp: ~U[2026-03-28 18:00:00Z],
- mode: "CW",
- band: Decimal.new("1296"),
- grid1: "EM12",
- grid2: "EM00",
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- distance_km: Decimal.new("295")
- }
-
- merged = Map.merge(default, attrs)
- {user_id, changeset_attrs} = Map.pop(merged, :user_id)
-
- {:ok, contact} =
- %Contact{user_id: user_id}
- |> Contact.changeset(changeset_attrs)
- |> Repo.insert()
-
- contact
- end
-
- describe "Index" do
- test "renders page with Contacts heading", %{conn: conn} do
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ "Contacts"
- end
-
- test "shows total contact count in subtitle", %{conn: conn} do
- create_contact()
- create_contact(%{station1: "N5AC"})
- create_contact(%{station1: "K5TR"})
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ "3 total"
- end
-
- test "table shows contact data", %{conn: conn} do
- create_contact()
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ "W5XD"
- assert html =~ "K5TR"
- assert html =~ "CW"
- assert html =~ "1296"
- end
-
- test "row links to detail page", %{conn: conn} do
- contact = create_contact()
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ ~p"/contacts/#{contact.id}"
- end
-
- test "sort params order the table", %{conn: conn} do
- create_contact(%{station1: "ZZ9ZZ"})
- create_contact(%{station1: "AA1AA"})
-
- {:ok, _lv, html} = live(conn, ~p"/contacts?sort_params[station1]=asc")
-
- aa_pos = html |> :binary.match("AA1AA") |> elem(0)
- zz_pos = html |> :binary.match("ZZ9ZZ") |> elem(0)
- assert aa_pos < zz_pos
- end
-
- test "search by single callsign matches either station", %{conn: conn} do
- create_contact(%{station1: "W5LUA", station2: "W5HN"})
- create_contact(%{station1: "K5TR", station2: "N5AC"})
-
- {:ok, _lv, html} = live(conn, ~p"/contacts?search=W5LUA")
- # W5LUA appears in the matching row; K5TR/N5AC should not render in
- # the table body because the search filtered that contact out.
- assert html =~ "W5LUA"
- refute html =~ "N5AC"
- end
-
- test "pagination reaches later pages", %{conn: conn} do
- for i <- 1..25 do
- ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
- create_contact(%{qso_timestamp: ts, station1: "W#{i}TEST"})
- end
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ "Page"
-
- {:ok, _lv, html2} = live(conn, ~p"/contacts?page=2")
- assert html2 =~ "Page"
- end
-
- test "renders a per-month bar chart that sums across years", %{conn: conn} do
- # Two March contacts in different years (2025, 2026) plus one
- # July: chart should report Mar=2 and Jul=1.
- create_contact(%{qso_timestamp: ~U[2025-03-15 12:00:00Z], station1: "W5MAR"})
- create_contact(%{qso_timestamp: ~U[2026-03-22 12:00:00Z], station1: "K5MAR"})
- create_contact(%{qso_timestamp: ~U[2026-07-10 12:00:00Z], station1: "W5JUL"})
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
-
- assert html =~ "Contacts by month"
- assert html =~ ~s|data-month="3"|
- assert html =~ ~s|data-month-count="2"|
- assert html =~ ~s|data-month="7"|
- assert html =~ ~s|data-month-count="1"|
- # Months without contacts still render with a 0 count so the
- # x-axis stays continuous.
- assert html =~ ~s|data-month="1"|
- assert html =~ ~s|data-month-count="0"|
- end
- end
-
- describe "Index private visibility" do
- import Microwaveprop.AccountsFixtures
-
- test "anonymous viewer does not see private contacts in the table", %{conn: conn} do
- _private = create_contact(%{station1: "W5PRV", private: true})
- _public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
-
- assert html =~ "W5PUB"
- refute html =~ "W5PRV"
- end
-
- test "owner sees their own private inline with Yes badge", %{conn: conn} do
- owner = user_fixture()
- _private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
- _public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
- conn = log_in_user(conn, owner)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
-
- assert html =~ "W5PRV"
- assert html =~ "W5PUB"
- assert html =~ "badge-warning"
- end
-
- test "admin sees all private contacts", %{conn: conn} do
- admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
- _private = create_contact(%{station1: "W5PRV", private: true})
- _public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
- conn = log_in_user(conn, admin)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ "W5PRV"
- assert html =~ "W5PUB"
- end
-
- test "non-owning user does not see others' private", %{conn: conn} do
- owner = user_fixture()
- viewer = user_fixture()
- _private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
- _public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
- conn = log_in_user(conn, viewer)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ "W5PUB"
- refute html =~ "W5PRV"
- end
- end
-
- describe "Index private column visibility" do
- import Microwaveprop.AccountsFixtures
-
- test "column hidden for anonymous viewer when no private contacts exist", %{conn: conn} do
- _public = create_contact(%{station1: "W5PUB"})
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- refute html =~ ">Private<"
- end
-
- test "column hidden for anonymous viewer even when others have private contacts", %{conn: conn} do
- _private = create_contact(%{station1: "W5PRV", private: true})
- _public = create_contact(%{station1: "W5PUB"})
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- refute html =~ ">Private<"
- end
-
- test "column hidden for a user who has no private contacts of their own", %{conn: conn} do
- owner = user_fixture()
- viewer = user_fixture()
- _others_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
- conn = log_in_user(conn, viewer)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- refute html =~ ">Private<"
- end
-
- test "column visible for a user with at least one of their own private contacts", %{conn: conn} do
- owner = user_fixture()
- _private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
- conn = log_in_user(conn, owner)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ ">Private<"
- end
-
- test "column hidden for admin when there are no private contacts anywhere", %{conn: conn} do
- admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
- _public = create_contact(%{station1: "W5PUB"})
- conn = log_in_user(conn, admin)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- refute html =~ ">Private<"
- end
-
- test "column visible for admin as soon as any private contact exists", %{conn: conn} do
- admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
- _private = create_contact(%{station1: "W5PRV", private: true})
- conn = log_in_user(conn, admin)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts")
- assert html =~ ">Private<"
- end
- end
-
- describe "Show private visibility" do
- import Microwaveprop.AccountsFixtures
-
- test "anonymous viewer 404s on a private contact", %{conn: conn} do
- contact = create_contact(%{private: true})
-
- assert_raise Ecto.NoResultsError, fn ->
- live(conn, ~p"/contacts/#{contact.id}")
- end
- end
-
- test "non-owning user 404s on a private contact", %{conn: conn} do
- owner = user_fixture()
- other = user_fixture()
- contact = create_contact(%{private: true, user_id: owner.id})
- conn = log_in_user(conn, other)
-
- assert_raise Ecto.NoResultsError, fn ->
- live(conn, ~p"/contacts/#{contact.id}")
- end
- end
-
- test "owner can view their own private contact", %{conn: conn} do
- owner = user_fixture()
- contact = create_contact(%{private: true, user_id: owner.id})
- conn = log_in_user(conn, owner)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "W5XD"
- end
-
- test "admin can view anyone's private contact", %{conn: conn} do
- owner = user_fixture()
- admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
- contact = create_contact(%{private: true, user_id: owner.id})
- conn = log_in_user(conn, admin)
-
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "W5XD"
- end
- end
-
- describe "Show" do
- test "renders contact detail fields", %{conn: conn} do
- contact = create_contact()
-
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "W5XD"
- assert html =~ "K5TR"
- # Mode, band, grids are shown in the elevation profile section (requires SRTM)
- # or in the header subtitle
- assert html =~ "2026-03-28"
- end
-
- test "shows surface observations section", %{conn: conn} do
- contact = create_contact()
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "Surface Observations"
- end
-
- test "shows sounding section", %{conn: conn} do
- contact = create_contact()
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "Soundings"
- end
-
- test "shows solar index section", %{conn: conn} do
- contact = create_contact()
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "Solar Conditions"
- end
-
- test "renders with sort assigns", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}")
-
- # Page renders without error
- assert html =~ "Surface Observations"
- assert html =~ "Soundings"
-
- # Sort assigns are initialized (no crash on render)
- assert render(lv) =~ contact.station1
- end
-
- # Band display on the detail page: below 10 GHz renders in MHz, at/above
- # 10 GHz renders in GHz. Amateur microwave convention β 2304 is "2304",
- # 3456 is "3456", 5760 is "5760", not "2.3 GHz" etc.
- test "renders sub-10-GHz bands in MHz", %{conn: conn} do
- contact = create_contact(%{band: Decimal.new("432")})
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "432 MHz"
- end
-
- test "renders 50 MHz in MHz", %{conn: conn} do
- contact = create_contact(%{band: Decimal.new("50")})
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "50 MHz"
- end
-
- test "renders 1296 MHz in MHz", %{conn: conn} do
- contact = create_contact(%{band: Decimal.new("1296")})
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "1296 MHz"
- refute html =~ "1.3 GHz"
- end
-
- test "renders 2304 MHz in MHz", %{conn: conn} do
- contact = create_contact(%{band: Decimal.new("2304")})
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "2304 MHz"
- refute html =~ "2.3 GHz"
- end
-
- test "renders 10 GHz in GHz (threshold)", %{conn: conn} do
- contact = create_contact(%{band: Decimal.new("10000")})
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "10 GHz"
- end
-
- test "renders 24 GHz in GHz", %{conn: conn} do
- contact = create_contact(%{band: Decimal.new("24000")})
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "24 GHz"
- end
-
- test "raises for bad UUID", %{conn: conn} do
- assert_raise Ecto.NoResultsError, fn ->
- live(conn, ~p"/contacts/#{Ecto.UUID.generate()}")
- end
- end
-
- test "shows atmospheric profile section with NARR source for pre-HRRR contact", %{conn: conn} do
- # Pre-2014 contact β HRRR is unavailable, NARR is the fallback source.
- contact =
- create_contact(%{
- qso_timestamp: ~U[2010-06-15 18:00:00Z],
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- {:ok, _} =
- Repo.insert(%NarrProfile{
- valid_time: ~U[2010-06-15 18:00:00Z],
- lat: 33.0,
- lon: -97.0,
- profile: [
- %{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0}
- ],
- surface_temp_c: 25.0,
- surface_dewpoint_c: 18.0,
- surface_pressure_mb: 1012.0,
- hpbl_m: 1200.0,
- pwat_mm: 30.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -50.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- html = render_async(lv, 2_000)
- assert html =~ "Atmospheric Profile"
- assert html =~ "NARR"
- # Collapsed summary shows HPBL / PWAT / lat,lon from the injected NARR row.
- assert html =~ "1200"
- assert html =~ "33.0"
- end
-
- test "skew-T uses native HRRR profile when backfilled", %{conn: conn} do
- # Native backfill has reached this hour β use its full-atmosphere
- # profile for the skew-T instead of the 700 mb-capped pressure-level one.
- contact =
- create_contact(%{
- qso_timestamp: ~U[2024-06-15 18:00:00Z],
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- # NARR row keeps the atmospheric profile section rendering with its
- # aggregate metadata (HPBL / PWAT / surface scalars).
- {:ok, _} =
- Repo.insert(%NarrProfile{
- valid_time: ~U[2024-06-15 18:00:00Z],
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 850.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 1500.0}],
- surface_temp_c: 25.0,
- surface_dewpoint_c: 18.0,
- surface_pressure_mb: 1012.0,
- hpbl_m: 1200.0,
- pwat_mm: 30.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -50.0,
- ducting_detected: false
- })
-
- # Native profile at the same point/time. `pressure_pa: [..., 30_000.0]`
- # corresponds to 300 mb β a level the pressure-level profile cannot
- # cover because HRRR/NARR pressure-level data stops at 700 mb historically.
- {:ok, _} =
- %HrrrNativeProfile{}
- |> HrrrNativeProfile.changeset(%{
- valid_time: ~U[2024-06-15 18:00:00Z],
- run_time: ~U[2024-06-15 18:00:00Z],
- lat: 32.9,
- lon: -97.0,
- level_count: 3,
- heights_m: [10.0, 1500.0, 9500.0],
- temp_k: [298.0, 288.0, 228.0],
- spfh: [0.012, 0.006, 0.0001],
- pressure_pa: [101_000.0, 85_000.0, 30_000.0],
- u_wind_ms: [2.0, 5.0, 20.0],
- v_wind_ms: [1.0, 2.0, 5.0],
- tke_m2s2: [0.5, 0.2, 0.05]
- })
- |> Repo.insert()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "HRRR native"
- # 300 mb comes from the native profile β pressure-level data capped at 700.
- assert html =~ "300.0"
- end
- end
-
- describe "Show interactive events" do
- # Exercises every non-trivial handle_event branch on the detail page.
- # These bump ContactLive.Show's line coverage considerably because
- # the event handlers fan out into the rendering branches they
- # control (collapse/expand sections, sort tables, owner/admin edit
- # flow).
-
- import Microwaveprop.AccountsFixtures
-
- defp admin_fixture do
- user = user_fixture()
- {:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true})
- user
- end
-
- test "toggle_hrrr_profile flips the atmospheric-profile collapse state", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- # Firing the event twice returns us to the starting state β proves
- # the handler is stateful and reachable.
- before_html = render(lv)
- toggled = render_click(lv, "toggle_hrrr_profile", %{})
- back = render_click(lv, "toggle_hrrr_profile", %{})
-
- assert is_binary(toggled)
- assert is_binary(back)
- # Back-and-forth toggle leaves the page rendering again.
- assert back =~ contact.station1 and before_html =~ contact.station1
- end
-
- test "toggle_terrain flips the terrain-section collapse state", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- html = render_click(lv, "toggle_terrain", %{})
- assert is_binary(html)
- end
-
- test "toggle_profile (sounding expansion) tracks an id in the MapSet", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- # Use a made-up id β even without a real sounding the handler path
- # still executes (MapSet add/remove) and keeps the LV alive.
- html = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
- assert is_binary(html)
-
- html2 = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"})
- assert is_binary(html2)
- end
-
- test "sort obs table by date toggles sort direction", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
- html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
- assert html =~ "Surface Observations"
- end
-
- test "sort obs table exercises every known field sort key", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- for field <- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb unknown_field) do
- html = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
- assert html =~ "Surface Observations", "sort by #{field} lost the obs table"
- end
- end
-
- test "sort soundings table exercises every known field sort key", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- for field <- ~w(station_name observed_at unknown_field) do
- html = render_click(lv, "sort", %{"field" => field, "table" => "soundings"})
- assert html =~ "Soundings", "sort by #{field} lost the soundings table"
- end
- end
-
- test "sort soundings table by date keeps the view rendering", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"})
- assert html =~ "Soundings"
- end
-
- test "toggle_flag is denied to anonymous viewers (flash + no change)", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- html = render_click(lv, "toggle_flag", %{})
- assert html =~ "Admins only"
-
- refute Repo.get!(Contact, contact.id).flagged_invalid
- end
-
- test "toggle_flag inverts the flag for admins", %{conn: conn} do
- contact = create_contact()
- admin = admin_fixture()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_flag", %{})
-
- refreshed = Repo.get!(Contact, contact.id)
- assert refreshed.flagged_invalid == true
-
- # Toggling again flips back.
- render_click(lv, "toggle_flag", %{})
- assert Repo.get!(Contact, contact.id).flagged_invalid == false
- end
-
- test "toggle_edit opens and closes the inline edit form", %{conn: conn} do
- contact = create_contact()
- admin = admin_fixture()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, html_before} = live(conn, ~p"/contacts/#{contact.id}")
- refute html_before =~ ~s(phx-submit="submit_edit")
-
- html_open = render_click(lv, "toggle_edit", %{})
- assert html_open =~ ~s(phx-submit="submit_edit")
-
- html_closed = render_click(lv, "toggle_edit", %{})
- refute html_closed =~ ~s(phx-submit="submit_edit")
- end
-
- test "submit_edit by a non-logged-in user flashes a login-required error", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{"station1" => "N5XX"}
- })
-
- assert html =~ "logged in to edit"
- end
-
- test "submit_edit as admin applies a simple callsign change", %{conn: conn} do
- # Avoid qso_timestamp and 'private' in the submitted form β both
- # have known diff-vs-DB encoding quirks (timestamp format mismatch;
- # false vs nil private) that would trip the admin apply path. A
- # clean station1 change exercises apply_admin_edit/3 end-to-end.
- contact = create_contact(%{station1: "N5OLD"})
- admin = admin_fixture()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => "N5NEW",
- "station2" => contact.station2,
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => contact.mode
- }
- })
-
- assert html =~ "Contact updated"
- assert Repo.get!(Contact, contact.id).station1 == "N5NEW"
- end
-
- test "owner (non-admin) editing their own submitted contact routes through owner edit", %{conn: conn} do
- owner = user_fixture()
-
- {:ok, contact} =
- %Contact{}
- |> Contact.changeset(%{
- station1: owner.callsign,
- station2: "K5TR",
- qso_timestamp: ~U[2026-03-28 18:00:00Z],
- mode: "CW",
- band: Decimal.new("1296"),
- grid1: "EM12",
- grid2: "EM00",
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- distance_km: Decimal.new("295"),
- user_submitted: true,
- submitter_email: owner.email
- })
- |> Ecto.Changeset.change(user_id: owner.id)
- |> Repo.insert()
-
- conn = log_in_user(conn, owner)
-
- {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- # Owners go through apply_owner_edit/4, which directly applies the
- # change to their own submission (no admin review needed).
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => owner.callsign,
- "station2" => "K9NEW",
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => contact.mode
- }
- })
-
- assert html =~ "Contact updated"
- assert Repo.get!(Contact, contact.id).station2 == "K9NEW"
- end
-
- test "non-owner non-admin submitting an edit creates a pending ContactEdit", %{conn: conn} do
- contact = create_contact()
- stranger = user_fixture()
- conn = log_in_user(conn, stranger)
-
- {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => contact.station1,
- "station2" => "K9SUGGEST",
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => contact.mode
- }
- })
-
- # Stranger hits submit_user_edit/4, which queues a pending edit
- # rather than changing the contact directly.
- assert Repo.get!(Contact, contact.id).station2 == contact.station2
-
- assert Repo.aggregate(Microwaveprop.Radio.ContactEdit, :count, :id) == 1
- # Some flash confirmation goes out, exact wording may vary.
- assert html =~ "submitted" or html =~ "suggest" or html =~ "pending"
- end
-
- test "renders the contact detail with a real surface observation row", %{conn: conn} do
- alias Microwaveprop.Weather
- alias Microwaveprop.Weather.SurfaceObservation
-
- contact = create_contact()
-
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: "KDFW",
- station_type: "asos",
- name: "DFW Airport",
- lat: 32.90,
- lon: -97.02
- })
-
- {:ok, _} =
- %SurfaceObservation{}
- |> SurfaceObservation.changeset(%{
- station_id: station.id,
- observed_at: ~U[2026-03-28 18:00:00Z],
- temp_f: 72.0,
- dewpoint_f: 55.0,
- relative_humidity: 55.0,
- sea_level_pressure_mb: 1015.0
- })
- |> Repo.insert()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- html = render_async(lv, 2_000)
- # Surface Observations section is present; actual rows may or may
- # not render depending on internal proximity/time filters. The
- # render pass itself exercises the with-data code path either way.
- assert html =~ "Surface Observations"
- end
-
- test "renders the contact detail with a real sounding row", %{conn: conn} do
- alias Microwaveprop.Weather
- alias Microwaveprop.Weather.Sounding
-
- contact = create_contact()
-
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: "KFWD",
- station_type: "sounding",
- name: "Fort Worth",
- lat: 32.83,
- lon: -97.30
- })
-
- {:ok, _} =
- %Sounding{}
- |> Sounding.changeset(%{
- station_id: station.id,
- observed_at: ~U[2026-03-28 12:00:00Z],
- profile: [
- %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
- %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
- ],
- level_count: 2,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 15.0
- })
- |> Repo.insert()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- html = render_async(lv, 2_000)
- assert html =~ "KFWD"
- end
-
- test "validate_edit always returns :noreply without touching the DB", %{conn: conn} do
- contact = create_contact()
- admin = admin_fixture()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_change(lv, "validate_edit", %{
- "edit" => %{"station1" => "N5TYPED"}
- })
-
- # Still editing, contact unchanged.
- assert html =~ ~s(phx-submit="submit_edit")
- assert Repo.get!(Contact, contact.id).station1 == "W5XD"
- end
- end
-
- describe "Show fully-hydrated render paths" do
- # These tests seed the DB with real-ish enrichment rows for a single
- # contact, then drive the LiveView through its async hydration so we
- # exercise the derived-analysis helpers (refresh_computed,
- # build_propagation_analysis, build_data_sources) and the render
- # branches they unlock.
-
- alias Microwaveprop.Propagation.ScoreCache
- alias Microwaveprop.Radio.ContactCommonVolumeRadar
- alias Microwaveprop.Terrain.TerrainProfile
- alias Microwaveprop.Weather
- alias Microwaveprop.Weather.HrrrProfile
- alias Microwaveprop.Weather.IemreObservation
- alias Microwaveprop.Weather.Sounding
- alias Microwaveprop.Weather.SurfaceObservation
-
- defp seed_fully_hydrated do
- contact =
- create_contact(%{
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- _ =
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [
- %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
- %{"pres" => 925.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 800.0},
- %{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
- ],
- hpbl_m: 1200.0,
- pwat_mm: 25.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 12.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -120.0,
- ducting_detected: false
- })
-
- _ =
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"km" => 0.0, "elev_m" => 200.0}, %{"km" => 295.0, "elev_m" => 250.0}],
- max_elevation_m: 450.0,
- min_clearance_m: 20.0,
- diffraction_db: 6.5,
- fresnel_hit_count: 3,
- obstructed_count: 1,
- verdict: "CLEAR"
- })
-
- _ =
- Repo.insert!(%ContactCommonVolumeRadar{
- contact_id: contact.id,
- observed_at: contact.qso_timestamp,
- common_volume_km2: 12_000.0,
- pixel_count: 500,
- rain_pixel_count: 5,
- heavy_rain_pixel_count: 0,
- max_dbz: 22.0,
- mean_dbz: 12.0,
- coverage_pct: 1.0
- })
-
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: "KDFW",
- station_type: "asos",
- name: "DFW Airport",
- lat: 32.9,
- lon: -97.02
- })
-
- Repo.insert!(
- SurfaceObservation.changeset(%SurfaceObservation{}, %{
- station_id: station.id,
- observed_at: contact.qso_timestamp,
- temp_f: 72.0,
- dewpoint_f: 55.0,
- relative_humidity: 55.0,
- sea_level_pressure_mb: 1015.0
- })
- )
-
- {:ok, raob_station} =
- Weather.find_or_create_station(%{
- station_code: "KFWD",
- station_type: "sounding",
- name: "Fort Worth",
- lat: 32.83,
- lon: -97.30
- })
-
- Repo.insert!(
- Sounding.changeset(%Sounding{}, %{
- station_id: raob_station.id,
- observed_at: ~U[2026-03-28 12:00:00Z],
- profile: [
- %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
- %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
- ],
- level_count: 2,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 15.0
- })
- )
-
- _ =
- Repo.insert!(%IemreObservation{
- date: ~D[2026-03-28],
- lat: 31.625,
- lon: -97.375,
- hourly: [
- %{"utc_hour" => 18, "air_temp_f" => 70.0, "dew_point_f" => 55.0, "skyc_pct" => 30.0}
- ]
- })
-
- contact
- end
-
- setup do
- ScoreCache.clear()
- :ok
- end
-
- test "fully populated contact renders every enrichment section", %{conn: conn} do
- contact = seed_fully_hydrated()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # Headers / enrichment labels visible when data is present.
- assert html =~ contact.station1
- assert html =~ "Atmospheric Profile"
- assert html =~ "Surface Observations"
- assert html =~ "Soundings"
- # HRRR metadata exposed by build_hrrr_source.
- assert html =~ "1200"
- # Terrain verdict surfaced by terrain_summary.
- assert html =~ "Line of sight is clear"
- end
-
- test "handle_info :terrain_ready refreshes the terrain section", %{conn: conn} do
- contact = seed_fully_hydrated()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # Simulate the TerrainProfileWorker broadcast β the listener
- # recomputes elevation_profile and propagation_analysis and
- # re-renders without crashing.
- send(lv.pid, {:terrain_ready, contact.id})
- html = render(lv)
- assert html =~ contact.station1
- end
-
- test "handle_info ignores terrain_ready for unrelated contact", %{conn: conn} do
- contact = seed_fully_hydrated()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- send(lv.pid, {:terrain_ready, Ecto.UUID.generate()})
- assert Process.alive?(lv.pid)
- end
-
- test "handle_info :sounding_fetched rehydrates the soundings table", %{conn: conn} do
- contact = seed_fully_hydrated()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- send(lv.pid, {:sounding_fetched, %{contact_id: contact.id}})
- html = render(lv)
- assert html =~ "Soundings"
- end
-
- test "handle_info :sounding_fetched for a different contact is a no-op", %{conn: conn} do
- contact = seed_fully_hydrated()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}})
- assert Process.alive?(lv.pid)
- end
-
- test "flagged_invalid contact shows the Flagged invalid badge", %{conn: conn} do
- contact = create_contact()
-
- {:ok, contact} =
- contact
- |> Ecto.Changeset.change(flagged_invalid: true)
- |> Repo.update()
-
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "Flagged invalid"
- # Header station text gets line-through styling when flagged.
- assert html =~ "line-through"
- end
-
- test "owner sees an Edit button (not Suggest Edit) for their own contact", %{conn: conn} do
- import Microwaveprop.AccountsFixtures
-
- owner = user_fixture()
-
- {:ok, contact} =
- %Contact{}
- |> Contact.changeset(%{
- station1: owner.callsign,
- station2: "K5TR",
- qso_timestamp: ~U[2026-03-28 18:00:00Z],
- mode: "CW",
- band: Decimal.new("1296"),
- grid1: "EM12",
- grid2: "EM00",
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- distance_km: Decimal.new("295"),
- user_submitted: true,
- submitter_email: owner.email
- })
- |> Ecto.Changeset.change(user_id: owner.id)
- |> Repo.insert()
-
- conn = log_in_user(conn, owner)
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
-
- # Owner gets the direct Edit label, not Suggest Edit.
- assert html =~ ">\n Edit\n " or html =~ "Edit"
- refute html =~ "Suggest Edit"
- end
- end
-
- describe "Show render-branch coverage" do
- # Coverage-focused tests for ContactLive.Show β each test drives the
- # LiveView far enough to render a template branch that the other
- # describe blocks don't reach. Together they push show.ex line
- # coverage well past 48% by forcing build_propagation_analysis,
- # build_data_sources, terrain_summary, the iemre render block, and
- # the can_enqueue=true enrichment kick-offs through every reachable
- # factor/verdict combination.
-
- use ExUnitProperties
-
- alias Microwaveprop.Propagation.ScoreCache
- alias Microwaveprop.Radio.ContactCommonVolumeRadar
- alias Microwaveprop.Radio.ContactEdit
- alias Microwaveprop.Terrain.TerrainProfile
- alias Microwaveprop.Weather
- alias Microwaveprop.Weather.HrrrProfile
- alias Microwaveprop.Weather.IemreObservation
- alias Microwaveprop.Weather.Sounding
- alias Microwaveprop.Weather.SurfaceObservation
-
- # Re-seed a richer variant of seed_fully_hydrated/0 so each branch test
- # can override the single row it cares about (terrain verdict, iemre
- # hour, propagation tier). Keeps a single insertion per source so the
- # page renders through build_propagation_analysis exactly once.
- defp seed_contact_with_overrides(opts \\ []) do
- contact_overrides = Keyword.get(opts, :contact, %{})
- terrain_overrides = Keyword.get(opts, :terrain, %{})
- hrrr_overrides = Keyword.get(opts, :hrrr, %{})
-
- contact =
- create_contact(
- Map.merge(
- %{
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- },
- contact_overrides
- )
- )
-
- hrrr_row =
- Map.merge(
- %{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [
- %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
- %{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
- ],
- hpbl_m: 1200.0,
- pwat_mm: 25.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 12.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -120.0,
- ducting_detected: false
- },
- hrrr_overrides
- )
-
- Repo.insert!(struct!(HrrrProfile, hrrr_row))
-
- terrain_row =
- Map.merge(
- %{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
- max_elevation_m: 450.0,
- min_clearance_m: 20.0,
- diffraction_db: 6.5,
- fresnel_hit_count: 0,
- obstructed_count: 0,
- verdict: "CLEAR"
- },
- terrain_overrides
- )
-
- Repo.insert!(struct!(TerrainProfile, terrain_row))
-
- contact
- end
-
- setup do
- ScoreCache.clear()
- :ok
- end
-
- test "IEMRE section renders nearest-hour temperature and dew point", %{conn: conn} do
- contact = create_contact()
-
- # IEMRE lookup snaps pos1 (32.9, -97.0) to the nearest 1/8Β° grid:
- # (32.875, -97.0). Seed exactly that grid point.
- Repo.insert!(%IemreObservation{
- date: DateTime.to_date(contact.qso_timestamp),
- lat: 32.875,
- lon: -97.0,
- hourly: [
- %{
- "utc_hour" => 18,
- "air_temp_f" => 73.4,
- "dew_point_f" => 54.3,
- "skyc_%" => 42.0,
- "uwnd_mps" => 3.0,
- "vwnd_mps" => 4.0,
- "hourly_precip_in" => 0.0
- }
- ]
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # Header label + the nearest-hour temperature value make it
- # through format_number. 73.4Β°F confirms the right hour bucket
- # came out of the Enum.min_by reduction.
- assert html =~ "IEMRE Gridded Reanalysis"
- assert html =~ "73.4"
- assert html =~ "54.3"
- end
-
- test "IEMRE section shows 'unavailable' status when marked as such", %{conn: conn} do
- contact =
- create_contact()
- |> Ecto.Changeset.change(iemre_status: :unavailable)
- |> Repo.update!()
-
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- assert html =~ "IEMRE data unavailable for this contact."
- end
-
- test "BLOCKED terrain verdict shows the obstruction badge class", %{conn: conn} do
- contact =
- seed_contact_with_overrides(
- terrain: %{
- verdict: "BLOCKED",
- obstructed_count: 4,
- fresnel_hit_count: 2,
- diffraction_db: 18.2
- }
- )
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # badge-error is the BLOCKED class; the diffraction_db value
- # surfaces through format_number in the verdict row.
- assert html =~ "BLOCKED"
- assert html =~ "badge-error"
- assert html =~ "obstructed"
- end
-
- test "FRESNEL_MINOR terrain verdict renders with warning styling", %{conn: conn} do
- contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_MINOR"})
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "FRESNEL_MINOR"
- assert html =~ "badge-warning"
- end
-
- test "FRESNEL_PARTIAL terrain verdict renders with warning styling", %{conn: conn} do
- contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_PARTIAL"})
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "FRESNEL_PARTIAL"
- assert html =~ "badge-warning"
- end
-
- test "expanded terrain table renders all path points", %{conn: conn} do
- contact =
- seed_contact_with_overrides(
- terrain: %{
- verdict: "CLEAR",
- path_points: [
- %{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0},
- %{"lat" => 31.6, "lon" => -97.35, "dist_km" => 147.5, "elev" => 320.0},
- %{"lat" => 30.3, "lon" => -97.7, "dist_km" => 295.0, "elev" => 250.0}
- ]
- }
- )
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # Expand the terrain section by firing toggle_terrain; the table
- # header "Dist (km)" only appears when @terrain_expanded is true.
- html = render_click(lv, "toggle_terrain", %{})
- assert html =~ "Dist (km)"
- end
-
- test "Propagation Analysis renders factor rows and composite tier label", %{conn: conn} do
- contact = seed_contact_with_overrides()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "Propagation Analysis"
- # Factor table header β only rendered when factors != nil.
- assert html =~ "Contribution"
- # Humidity is always in the factor_rows list; its row label proves
- # build_propagation_analysis -> compute_factors -> humidity_note
- # all executed.
- assert html =~ "Humidity"
- # composite_score is rendered as N/100; at minimum the "/100" marker
- # plus one of the tier labels must appear.
- assert html =~ "/100"
-
- assert html =~ "EXCELLENT" or html =~ "GOOD" or html =~ "MARGINAL" or
- html =~ "POOR" or html =~ "NEGLIGIBLE"
- end
-
- test "Data Sources card renders HRRR, Terrain, and Elevation blocks", %{conn: conn} do
- contact = seed_contact_with_overrides()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "Data Sources"
- assert html =~ "HRRR Model"
- assert html =~ "Terrain Analysis"
- assert html =~ "Elevation Profile"
- end
-
- test "internal-network conn kicks off enqueue path and transitions pending status", %{conn: conn} do
- # A contact with only coords + pending everything triggers every
- # maybe_enqueue_* branch once can_enqueue is true. We never run
- # Oban inline during the test; we just assert the hydration
- # finishes without crashing the LiveView.
- contact = create_contact()
-
- # `store_remote_ip` pulls conn.remote_ip into the session. Seeding
- # it into the @enqueue_subnet CIDR makes internal_network?/1 true.
- conn = %{conn | remote_ip: {172, 56, 0, 1}}
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- assert Process.alive?(lv.pid)
- # The page still renders both station callsigns; no enrichment
- # row exists, so the fallback spinners / "No *" copy is fine.
- assert render(lv) =~ contact.station1
- end
-
- test "radar row surfaces max_dbz and coverage_pct in the mechanism block", %{conn: conn} do
- contact = create_contact()
-
- Repo.insert!(%ContactCommonVolumeRadar{
- contact_id: contact.id,
- observed_at: contact.qso_timestamp,
- common_volume_km2: 12_500.0,
- pixel_count: 800,
- rain_pixel_count: 150,
- heavy_rain_pixel_count: 25,
- max_dbz: 47.5,
- mean_dbz: 22.3,
- coverage_pct: 18.0
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "Propagation Mechanism"
- # Max reflectivity + heavy-rain pixel count only render when
- # @radar is populated β i.e. after handle_async(:radar, β¦) lands.
- assert html =~ "47.5 dBZ"
- assert html =~ "Heavy-rain pixels: 25"
- end
-
- test "non-owner with a pending edit sees the pending-edit banner", %{conn: conn} do
- import Microwaveprop.AccountsFixtures
-
- contact = create_contact()
- stranger = user_fixture()
-
- Repo.insert!(%ContactEdit{
- contact_id: contact.id,
- user_id: stranger.id,
- proposed_changes: %{"station2" => "K9EDIT"},
- status: :pending
- })
-
- conn = log_in_user(conn, stranger)
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
-
- assert html =~ "pending edit for this contact awaiting admin review"
- end
-
- test "full Atmospheric Profile block renders surface temp/dewpoint/pressure", %{conn: conn} do
- contact = seed_contact_with_overrides()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # All three surface scalars are only rendered once the atmospheric
- # profile card expands β hrrr_profile_expanded defaults to true.
- assert html =~ "Surface Temp:"
- assert html =~ "Surface Dewpoint:"
- assert html =~ "Surface Pressure:"
- end
-
- test "soundings-empty state shows the 1000 km fallback copy", %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # No soundings within radius + nothing queued => final <% true -> %>
- # branch of the soundings empty cond.
- assert html =~ "No soundings found within 1000 km."
- end
-
- test "surface-observations queued status shows spinner copy", %{conn: conn} do
- contact =
- create_contact()
- |> Ecto.Changeset.change(weather_status: :queued)
- |> Repo.update!()
-
- {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
-
- # Initial render (before render_async) sees @surface_observations=[]
- # and @contact.weather_status == :queued β the surface observations
- # spinner copy is reachable there.
- assert html =~ "Fetching surface observations" or html =~ "Surface Observations"
- end
-
- test "ducting detected on the HRRR profile shows the Ducting badge", %{conn: conn} do
- contact =
- seed_contact_with_overrides(
- hrrr: %{
- ducting_detected: true,
- min_refractivity_gradient: -200.0
- }
- )
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # Ducting badge appears next to the HRRR metadata line when the
- # profile reports ducting_detected=true.
- assert html =~ "Ducting"
- end
-
- test "sounding row with a real station shows the ducting detection badge", %{conn: conn} do
- contact = create_contact()
-
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: "KFWD",
- station_type: "sounding",
- name: "Fort Worth",
- lat: 32.83,
- lon: -97.30
- })
-
- Repo.insert!(
- Sounding.changeset(%Sounding{}, %{
- station_id: station.id,
- observed_at: ~U[2026-03-28 12:00:00Z],
- profile: [
- %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
- %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
- ],
- level_count: 2,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 15.0,
- surface_refractivity: 340.0,
- ducting_detected: true
- })
- )
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # Ducting badge renders inside the sounding button only for
- # rows with ducting_detected=true.
- assert html =~ "KFWD"
- assert html =~ "Ducting"
- end
-
- # βββ Property tests ββββββββββββββββββββββββββββββββββββββββββ
-
- property "obs-table sort handler is total over every known field and direction", %{conn: conn} do
- # Drive the sort handler with every documented obs field in
- # arbitrary order. The LV must keep the table rendered and not
- # drop / duplicate any seeded row. Field coverage here exercises
- # each clause of obs_sort_key/1 plus the fall-through default.
- contact = create_contact(%{station1: "W5PROP", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
-
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: "KDFW",
- station_type: "asos",
- name: "DFW Airport",
- lat: 32.9,
- lon: -97.02
- })
-
- Repo.insert!(
- SurfaceObservation.changeset(%SurfaceObservation{}, %{
- station_id: station.id,
- observed_at: contact.qso_timestamp,
- temp_f: 70.0,
- dewpoint_f: 55.0,
- relative_humidity: 55.0,
- sea_level_pressure_mb: 1015.0
- })
- )
-
- known_fields =
- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb zzz_unknown)
-
- field_stream = StreamData.member_of(known_fields)
- sequence_gen = StreamData.list_of(field_stream, min_length: 1, max_length: 6)
-
- check all(fields <- sequence_gen, max_runs: 15) do
- # One LV per run keeps each iteration's sort history isolated β
- # the sort handler flips between asc/desc based on prior state.
- {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- for f <- fields do
- html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
- assert html =~ "Surface Observations", "sort by #{f} lost the obs table"
- end
-
- assert Process.alive?(lv.pid)
- end
- end
-
- property "soundings-table sort handler tolerates arbitrary field keys", %{conn: conn} do
- # Mirrors the obs property β any sequence of known + unknown
- # sounding sort fields should leave the LV alive with the
- # Soundings heading intact.
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
-
- fields_pool =
- ~w(station_name observed_at surface_temp_c surface_refractivity k_index lifted_index unknown)
-
- sequence_gen =
- StreamData.list_of(StreamData.member_of(fields_pool), min_length: 1, max_length: 4)
-
- check all(fields <- sequence_gen, max_runs: 10) do
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- for f <- fields do
- html = render_click(lv, "sort", %{"field" => f, "table" => "soundings"})
- assert html =~ "Soundings", "sort by #{f} lost the soundings section"
- end
-
- assert Process.alive?(lv.pid)
- end
- end
- end
-
- describe "Show additional branch coverage" do
- # Extra tests aimed at previously-uncovered helpers in
- # ContactLive.Show: factor note helpers (exercised via the factor
- # table), propagation_mechanism branches, admin/owner/stranger
- # edit paths with error changesets, internal_network? edge cases,
- # elevation profile hydration with a populated SRTM stub, and
- # handle_info({:sounding_fetched, β¦}) with a non-matching id.
-
- use ExUnitProperties
-
- import Microwaveprop.AccountsFixtures
-
- alias Microwaveprop.Propagation.BandConfig
- alias Microwaveprop.Propagation.ScoreCache
- alias Microwaveprop.Propagation.Scorer
- alias Microwaveprop.Radio.ContactCommonVolumeRadar
- alias Microwaveprop.Radio.ContactEdit
- alias Microwaveprop.Terrain.TerrainProfile
- alias Microwaveprop.Weather.HrrrProfile
-
- defp admin_fixture! do
- user = user_fixture()
- {:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true})
- user
- end
-
- setup do
- ScoreCache.clear()
- :ok
- end
-
- test "factor notes render every note-branch for a 10 GHz contact", %{conn: conn} do
- # Drives humidity_note (beneficial branch), time_note (solar_period
- # evening β hour 18 UTC with lon -97), td_note (moderate, 72-55=17),
- # refractivity_note (ducting/super-refractive branch), sky_note,
- # season_note (March = summer peak band inverted), wind_note,
- # rain_note (None), pwat_note, pressure_note (high branch).
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- qso_timestamp: ~U[2026-03-28 18:00:00Z]
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [
- %{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 12.0, "hght" => 100.0}
- ],
- hpbl_m: 1200.0,
- pwat_mm: 32.0,
- surface_temp_c: 22.0,
- surface_dewpoint_c: 12.0,
- surface_pressure_mb: 1020.0,
- surface_refractivity: 340.0,
- min_refractivity_gradient: -160.0,
- ducting_detected: true
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "beneficial at this band"
- assert html =~ "ducting"
- # High pressure label from pressure_note (1020 >= 1015)
- assert html =~ "(high)"
- # 10G = :beneficial humidity_effect -> "summer peak band"
- assert html =~ "summer peak band"
- # pwat_note at 10G band picks the "refractivity aid" branch
- assert html =~ "refractivity aid"
- # rain_note returns "None" when rate is 0
- assert html =~ "None"
- end
-
- test "factor notes render 'absorption' and 'winter peak' branches at 24 GHz", %{conn: conn} do
- # At 24 GHz the band_config flips humidity_effect to :harmful, so
- # pwat_note hits the "absorption" branch and season_note prints
- # "winter peak".
- contact =
- create_contact(%{
- band: Decimal.new("24000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- qso_timestamp: ~U[2026-01-15 18:00:00Z]
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [
- %{"pres" => 1000.0, "tmpc" => 5.0, "dwpc" => -2.0, "hght" => 100.0}
- ],
- hpbl_m: 1200.0,
- pwat_mm: 28.0,
- surface_temp_c: 5.0,
- surface_dewpoint_c: -2.0,
- surface_pressure_mb: 1000.0,
- surface_refractivity: 310.0,
- min_refractivity_gradient: -50.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "harmful at this band"
- assert html =~ "absorption"
- assert html =~ "winter peak band"
- # pressure_note "low" branch (1000 < 1005)
- assert html =~ "(low)"
- end
-
- test "td_note renders 'near saturation' when dewpoint hugs temperature", %{conn: conn} do
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 19.5, "hght" => 100.0}],
- hpbl_m: 800.0,
- pwat_mm: 25.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 19.5,
- surface_pressure_mb: 1010.0,
- surface_refractivity: 340.0,
- min_refractivity_gradient: -90.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # near saturation branch of td_note when T-Td < 3
- assert html =~ "near saturation"
- # refractivity_note "enhanced" branch (-90 is between -100 and -79)
- assert html =~ "enhanced"
- # pressure_note "moderate" branch (1010 in [1005, 1015))
- assert html =~ "(moderate)"
- end
-
- test "td_note renders 'dry' branch for a large T-Td depression", %{conn: conn} do
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 30.0, "dwpc" => 0.0, "hght" => 100.0}],
- hpbl_m: 2200.0,
- pwat_mm: 8.0,
- surface_temp_c: 30.0,
- surface_dewpoint_c: 0.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 280.0,
- min_refractivity_gradient: -40.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # T-Td = ~54Β°F (30C vs 0C = 86F vs 32F) -> "dry" branch
- assert html =~ "(dry)"
- # refractivity_note "normal" branch (grad -40 > -79)
- assert html =~ "(normal)"
- end
-
- test "BLOCKED terrain with ducting selects the 'atmospheric ducting' mechanism copy", %{conn: conn} do
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}],
- hpbl_m: 800.0,
- pwat_mm: 30.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 15.0,
- surface_pressure_mb: 1015.0,
- surface_refractivity: 340.0,
- min_refractivity_gradient: -180.0,
- ducting_detected: true
- })
-
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
- max_elevation_m: 800.0,
- min_clearance_m: -50.0,
- diffraction_db: 22.0,
- fresnel_hit_count: 5,
- obstructed_count: 4,
- verdict: "BLOCKED"
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # blocked_mechanism with ducting? true, likely_duct nil (no elevation profile
- # hydrated here because the Req.Test stub returns []), so we get the
- # "bypassing the terrain obstruction" copy.
- assert html =~ "tropospheric duct" or html =~ "atmospheric ducting"
- end
-
- test "BLOCKED terrain with enhanced refraction but no ducting uses the 'dN/dh' copy", %{conn: conn} do
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
- hpbl_m: 1200.0,
- pwat_mm: 20.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 10.0,
- surface_pressure_mb: 1015.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -140.0,
- ducting_detected: false
- })
-
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
- max_elevation_m: 800.0,
- min_clearance_m: -20.0,
- diffraction_db: 18.0,
- fresnel_hit_count: 3,
- obstructed_count: 2,
- verdict: "BLOCKED"
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # Enhanced-refraction blocked_mechanism branch β prints "dN/dh = β¦"
- assert html =~ "dN/dh ="
- assert html =~ "Enhanced refraction"
- end
-
- test "BLOCKED terrain with no ducting or refraction falls back to diffraction copy", %{conn: conn} do
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
- hpbl_m: 1200.0,
- pwat_mm: 20.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 10.0,
- surface_pressure_mb: 1015.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -30.0,
- ducting_detected: false
- })
-
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
- max_elevation_m: 800.0,
- min_clearance_m: -20.0,
- diffraction_db: 14.5,
- fresnel_hit_count: 3,
- obstructed_count: 2,
- verdict: "BLOCKED"
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "diffraction loss"
- assert html =~ "knife-edge diffraction"
- end
-
- test "clear path with long distance + ducting renders 'extending range' copy", %{conn: conn} do
- # distance_km > 100 AND ducting_detected β clear_path_mechanism
- # ducting branch (no likely_duct since no elevation profile).
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- distance_km: Decimal.new("295")
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 20.0, "hght" => 100.0}],
- hpbl_m: 700.0,
- pwat_mm: 30.0,
- surface_temp_c: 22.0,
- surface_dewpoint_c: 20.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 350.0,
- min_refractivity_gradient: -200.0,
- ducting_detected: true
- })
-
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
- max_elevation_m: 400.0,
- min_clearance_m: 50.0,
- diffraction_db: 0.0,
- fresnel_hit_count: 0,
- obstructed_count: 0,
- verdict: "CLEAR"
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "Ducting conditions present"
- assert html =~ "extended range" or html =~ "extending range"
- end
-
- test "clear path with enhanced refraction but no ducting hits the long-path branch", %{conn: conn} do
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- distance_km: Decimal.new("200")
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
- hpbl_m: 1200.0,
- pwat_mm: 20.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 10.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -140.0,
- ducting_detected: false
- })
-
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
- max_elevation_m: 400.0,
- min_clearance_m: 50.0,
- diffraction_db: 0.0,
- fresnel_hit_count: 0,
- obstructed_count: 0,
- verdict: "CLEAR"
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # clear_path_mechanism(enhanced_refraction?: true, long_path?: true)
- assert html =~ "Enhanced refraction present"
- end
-
- test "admin edit of private toggle and height triggers the diff branch", %{conn: conn} do
- # apply_admin_edit β Radio.diff_against_contact β Radio.normalize_proposed
- # covering the normalize_boolean_field("true") + normalize_integer_field.
- contact = create_contact()
- admin = admin_fixture!()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => contact.station1,
- "station2" => contact.station2,
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => contact.mode,
- "height1_ft" => "35",
- "private" => "true"
- }
- })
-
- assert html =~ "Contact updated"
-
- refreshed = Repo.get!(Contact, contact.id)
- assert refreshed.height1_ft == 35
- assert refreshed.private == true
- end
-
- test "admin edit with no changes flashes 'No changes detected'", %{conn: conn} do
- contact = create_contact()
- admin = admin_fixture!()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => contact.station1,
- "station2" => contact.station2,
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => contact.mode
- }
- })
-
- assert html =~ "No changes detected"
- end
-
- test "owner submit with identical values returns no-changes flash", %{conn: conn} do
- # apply_owner_edit β diffed == %{} β {:error, :no_changes}
- owner = user_fixture()
-
- {:ok, contact} =
- %Contact{}
- |> Contact.changeset(%{
- station1: owner.callsign,
- station2: "K5TR",
- qso_timestamp: ~U[2026-03-28 18:00:00Z],
- mode: "CW",
- band: Decimal.new("1296"),
- grid1: "EM12",
- grid2: "EM00",
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- distance_km: Decimal.new("295"),
- user_submitted: true,
- submitter_email: owner.email
- })
- |> Ecto.Changeset.change(user_id: owner.id)
- |> Repo.insert()
-
- conn = log_in_user(conn, owner)
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => contact.station1,
- "station2" => contact.station2,
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => contact.mode
- }
- })
-
- assert html =~ "No changes detected"
- end
-
- test "stranger submit with an invalid mode surfaces the changeset error flash", %{conn: conn} do
- # submit_user_edit β Radio.create_contact_edit β {:error, changeset}
- # because "BOGUS" is not in the allowed mode list.
- contact = create_contact()
- stranger = user_fixture()
- conn = log_in_user(conn, stranger)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => contact.station1,
- "station2" => contact.station2,
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => "BOGUS"
- }
- })
-
- assert html =~ "Could not submit edit"
- # No ContactEdit row was inserted for the invalid submission.
- assert Repo.aggregate(ContactEdit, :count, :id) == 0
- end
-
- test "internal_network? false when no session IP is recorded", %{conn: conn} do
- # conn.remote_ip is {127, 0, 0, 1} by default β plug serializes
- # that into session["remote_ip"] as "127.0.0.1", which is outside
- # the @enqueue_subnet (172.56.0.0/13). can_enqueue must be false.
- contact = create_contact()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- refute :sys.get_state(lv.pid).socket.assigns.can_enqueue
- end
-
- test "internal_network? false for an IP just outside the enqueue subnet", %{conn: conn} do
- # 8.8.8.8 is nowhere near 172.56.0.0/13 β exercises the subnet
- # match returning false inside the parseable-IP branch.
- conn = %{conn | remote_ip: {8, 8, 8, 8}}
- contact = create_contact()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- refute :sys.get_state(lv.pid).socket.assigns.can_enqueue
- end
-
- test "internal_network? true when session IP sits inside the enqueue subnet", %{conn: conn} do
- # The store_remote_ip plug sources from conn.remote_ip, so override
- # the tuple directly β the plug serializes it into session["remote_ip"]
- # where internal_network?/1 picks it up.
- conn = %{conn | remote_ip: {172, 56, 0, 10}}
- contact = create_contact()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- assert :sys.get_state(lv.pid).socket.assigns.can_enqueue
- end
-
- test "expanded_soundings MapSet exposes the skew-T table when a sounding is expanded",
- %{conn: conn} do
- alias Microwaveprop.Weather
- alias Microwaveprop.Weather.Sounding
-
- contact = create_contact()
-
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: "KFWD",
- station_type: "sounding",
- name: "Fort Worth",
- lat: 32.83,
- lon: -97.30
- })
-
- {:ok, sounding} =
- %Sounding{}
- |> Sounding.changeset(%{
- station_id: station.id,
- observed_at: ~U[2026-03-28 12:00:00Z],
- profile: [
- %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
- %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
- ],
- level_count: 2,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 15.0
- })
- |> Repo.insert()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # Fire toggle_profile twice: once to open, once to close. Between
- # the two we should see the expanded table's "Wind Dir" header;
- # after the second click the expanded container is hidden again.
- html_open = render_click(lv, "toggle_profile", %{"id" => sounding.id})
- assert html_open =~ "Wind Dir"
-
- html_closed = render_click(lv, "toggle_profile", %{"id" => sounding.id})
- refute html_closed =~ "Wind Dir"
- end
-
- test "load_pending_edit returns the existing edit for a logged-in user", %{conn: conn} do
- # Hits the %{current_scope: %{user: %{id: user_id}}} branch of
- # load_pending_edit/2, which otherwise only runs for anonymous.
- contact = create_contact()
- user = user_fixture()
-
- Repo.insert!(%ContactEdit{
- contact_id: contact.id,
- user_id: user.id,
- proposed_changes: %{"station2" => "K9EDIT"},
- status: :pending
- })
-
- conn = log_in_user(conn, user)
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- assigns = :sys.get_state(lv.pid).socket.assigns
- assert %ContactEdit{status: :pending} = assigns.pending_edit
- end
-
- test "fetch_queue_counts hits the Cache.fetch_or_store round trip", %{conn: conn} do
- alias Microwaveprop.Workers.SolarIndexWorker
-
- # Inserting a real Oban job before mount means fetch_queue_counts
- # has real data to return, exercising the import+query branch of
- # the private helper via Cache.fetch_or_store.
- {:ok, _job} = Oban.insert(SolarIndexWorker.new(%{"date" => "2026-03-28"}))
-
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
-
- counts = :sys.get_state(lv.pid).socket.assigns.queue_counts
- assert is_map(counts)
- end
-
- test "handle_info({:sounding_fetched, β¦}) with mismatched contact_id is a no-op",
- %{conn: conn} do
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # The mismatched-id branch is the `else` of the id-equality check
- # inside handle_info β it must leave the LV alive and the
- # soundings assign untouched.
- soundings_before = :sys.get_state(lv.pid).socket.assigns.soundings
- send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}})
- # Give the message time to be processed.
- _ = :sys.get_state(lv.pid)
-
- assert Process.alive?(lv.pid)
- assert :sys.get_state(lv.pid).socket.assigns.soundings == soundings_before
- end
-
- test "rain_scatter mechanism surfaces for a 24 GHz contact with heavy rain pixels",
- %{conn: conn} do
- # Radar row with high max_dbz + heavy_rain_pixel_count plus a high
- # band drives the RainScatterClassifier into its rain-scatter
- # label. The Mechanism component renders the resulting label.
- contact =
- create_contact(%{
- band: Decimal.new("24000"),
- distance_km: Decimal.new("250")
- })
-
- Repo.insert!(%ContactCommonVolumeRadar{
- contact_id: contact.id,
- observed_at: contact.qso_timestamp,
- common_volume_km2: 15_000.0,
- pixel_count: 1200,
- rain_pixel_count: 400,
- heavy_rain_pixel_count: 120,
- max_dbz: 52.0,
- mean_dbz: 35.0,
- coverage_pct: 33.0
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # The radar summary row surfaces max_dbz + coverage_pct.
- assert html =~ "52.0 dBZ"
- assert html =~ "Heavy-rain pixels: 120"
- # Propagation Mechanism headline always renders.
- assert html =~ "Propagation Mechanism"
- end
-
- test "elevation profile hydrates when the SRTM stub returns a populated path",
- %{conn: conn} do
- # The default ElevationClient stub returns []; override it for this
- # test to return a simple synthetic profile in open-meteo shape.
- # That drives compute_elevation_profile β TerrainAnalysis.analyse β
- # extract_ducts β merge_nearby_ducts β mark_likely_duct.
- Req.Test.stub(ElevationClient, fn conn ->
- if conn.host == "api.open-meteo.com" do
- n = 257
- Req.Test.json(conn, %{"elevation" => List.duplicate(300.0, n)})
- else
- Req.Test.json(conn, [])
- end
- end)
-
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
- hpbl_m: 1200.0,
- pwat_mm: 25.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 10.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -200.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # The elevation_profile assign is populated only when
- # compute_elevation_profile returns a %{} β i.e. the SRTM stub
- # succeeded and TerrainAnalysis.analyse returned a result.
- ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile
- assert ep
- assert is_list(ep.points)
- # Ducts were inferred from the strong min_refractivity_gradient.
- assert is_list(ep.ducts)
- end
-
- # βββ Property tests ββββββββββββββββββββββββββββββββββββββββββ
-
- property "Scorer.composite_score always stays within [0, 100]" do
- # Generator drives path-integrated conditions through every band
- # in BandConfig to guarantee the tier label / class branches in
- # show.ex (which consume this exact composite score) never see
- # an out-of-range input. Keeps the weight-sum contract honest.
- all_band_mhz = BandConfig.all_freqs()
-
- check all(
- band_mhz <- StreamData.member_of(all_band_mhz),
- temp_c <- StreamData.float(min: -40.0, max: 50.0),
- td_depression <- StreamData.float(min: 0.0, max: 25.0),
- pressure_mb <- StreamData.float(min: 950.0, max: 1050.0),
- sky_pct <- StreamData.float(min: 0.0, max: 100.0),
- wind_kts <- StreamData.float(min: 0.0, max: 40.0),
- grad <- StreamData.float(min: -300.0, max: 50.0),
- pwat_mm <- StreamData.float(min: 0.0, max: 60.0),
- month <- StreamData.integer(1..12),
- hour <- StreamData.integer(0..23),
- max_runs: 30
- ) do
- band_config = BandConfig.get(band_mhz)
-
- conditions = %{
- temp_f: temp_c * 9 / 5 + 32,
- dewpoint_f: (temp_c - td_depression) * 9 / 5 + 32,
- temp_c: temp_c,
- dewpoint_c: temp_c - td_depression,
- abs_humidity: max(0.1, 10.0 - td_depression * 0.3),
- min_refractivity_gradient: grad,
- bl_depth_m: 1000.0,
- pressure_mb: pressure_mb,
- prev_pressure_mb: pressure_mb,
- sky_cover_pct: sky_pct,
- wind_speed_kts: wind_kts,
- rain_rate_mmhr: 0.0,
- pwat_mm: pwat_mm,
- month: month,
- utc_hour: hour,
- utc_minute: 0,
- latitude: 32.0,
- longitude: -97.0,
- best_duct_band_ghz: nil,
- bulk_richardson: nil
- }
-
- %{score: score} = Scorer.composite_score(conditions, band_config)
- assert is_integer(score)
- assert score >= 0 and score <= 100
- end
- end
-
- property "obs-table sort handler round-trips for any known field",
- %{conn: conn} do
- # Invariant: starting from a priming sort of a *different* field
- # (so the field-under-test is not yet current), clicking the
- # field-under-test walks asc β desc β asc. Drives toggle_sort/3
- # through both of its branches on each check.
- contact = create_contact()
-
- fields_pool =
- StreamData.member_of(~w(observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb))
-
- check all(field <- fields_pool, max_runs: 12) do
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # Prime with a different field so the first click of `field`
- # unambiguously hits the "new field β asc" branch.
- _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"})
-
- _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
- a1 = :sys.get_state(lv.pid).socket.assigns
- assert a1.obs_sort_by == field
- assert a1.obs_sort_order == "asc"
-
- _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
- a2 = :sys.get_state(lv.pid).socket.assigns
- assert a2.obs_sort_order == "desc"
-
- _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
- a3 = :sys.get_state(lv.pid).socket.assigns
- assert a3.obs_sort_order == "asc"
- end
- end
- end
-
- describe "Show failure-state + enrichment-combo render branches" do
- # Push ContactLive.Show past 85% line coverage by hitting render
- # branches not driven by the other describe blocks: enrichment
- # failures (:failed / :unavailable) on every pipeline, the
- # "HRRR nil + NARR present" fallback rail, radar-only mechanism
- # when HRRR is absent, sounding-sourced surface refractivity, and
- # the :solar handle_async branches (success + already-present).
-
- use ExUnitProperties
-
- import Microwaveprop.AccountsFixtures
-
- alias Microwaveprop.Propagation.ScoreCache
- alias Microwaveprop.Radio.ContactCommonVolumeRadar
- alias Microwaveprop.Terrain.TerrainProfile
- alias Microwaveprop.Weather
- alias Microwaveprop.Weather.HrrrProfile
- alias Microwaveprop.Weather.SolarIndex
- alias Microwaveprop.Weather.Sounding
- alias Microwaveprop.Weather.SurfaceObservation
-
- setup do
- ScoreCache.clear()
- :ok
- end
-
- # -- Enrichment failure surfaces ------------------------------------
-
- test "terrain_status :failed with no terrain row falls back to the no-profile copy",
- %{conn: conn} do
- contact =
- create_contact()
- |> Ecto.Changeset.change(terrain_status: :failed)
- |> Repo.update!()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # Neither the queued spinner nor a terrain row renders β the
- # :failed status lands in the `true ->` catch-all branch of the
- # terrain empty cond.
- assert html =~ "No terrain profile available."
- refute html =~ "Computing terrain profile"
- end
-
- test "iemre_status :failed shows the generic 'No IEMRE data' copy", %{conn: conn} do
- # :failed is not :queued and not :unavailable β the iemre empty
- # case hits the `_ ->` clause.
- contact =
- create_contact()
- |> Ecto.Changeset.change(iemre_status: :failed)
- |> Repo.update!()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "No IEMRE data available."
- refute html =~ "Fetching IEMRE data"
- end
-
- test "weather_status :failed renders the empty surface-obs copy with no spinner",
- %{conn: conn} do
- contact =
- create_contact()
- |> Ecto.Changeset.change(weather_status: :failed)
- |> Repo.update!()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # :failed is neither :queued nor :complete, so we land on the
- # plain "No surface observations found nearby." branch.
- assert html =~ "No surface observations found nearby."
- end
-
- test "hrrr_status :unavailable + no NARR row shows the NARR-fallback spinner",
- %{conn: conn} do
- # HRRR unavailable with NARR still not present β the atmospheric
- # profile section picks the "HRRR unavailable β fetching NARR
- # reanalysis" spinner branch.
- contact =
- create_contact()
- |> Ecto.Changeset.change(hrrr_status: :unavailable)
- |> Repo.update!()
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "HRRR unavailable"
- assert html =~ "fetching NARR reanalysis"
- end
-
- test "NARR fallback with HRRR nil renders atmospheric profile from NARR", %{conn: conn} do
- # HRRR stays nil; a NARR row at pos1 drives the `@narr` branch
- # of the atmospheric profile source cond.
- contact =
- create_contact(%{
- qso_timestamp: ~U[2010-06-15 18:00:00Z],
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%NarrProfile{
- valid_time: ~U[2010-06-15 18:00:00Z],
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 850.0, "tmpc" => 14.0, "dwpc" => 10.0, "hght" => 1500.0}],
- surface_temp_c: 24.0,
- surface_dewpoint_c: 18.0,
- surface_pressure_mb: 1012.0,
- hpbl_m: 1150.0,
- pwat_mm: 28.0,
- surface_refractivity: 318.0,
- min_refractivity_gradient: -55.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # NARR-specific badge text + collapsed summary values.
- assert html =~ "NARR"
- # Expanded profile card shows surface scalars.
- assert html =~ "Surface Temp:"
- assert html =~ "Surface Dewpoint:"
- end
-
- test "radar row without HRRR still renders the mechanism block and radar summary",
- %{conn: conn} do
- # No HRRR profile seeded β mechanism classifier runs with
- # duct_either_endpoint=false, coverage_pct drives the final
- # label. The render path exercises the radar block guard
- # (`@radar` non-nil) independently of HRRR.
- contact = create_contact(%{band: Decimal.new("10000"), distance_km: Decimal.new("150")})
-
- Repo.insert!(%ContactCommonVolumeRadar{
- contact_id: contact.id,
- observed_at: contact.qso_timestamp,
- common_volume_km2: 14_000.0,
- pixel_count: 900,
- rain_pixel_count: 60,
- heavy_rain_pixel_count: 8,
- max_dbz: 38.0,
- mean_dbz: 19.0,
- coverage_pct: 6.0
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "Propagation Mechanism"
- assert html =~ "38.0 dBZ"
- assert html =~ "Heavy-rain pixels: 8"
- assert html =~ "Radar coverage: 6%"
- end
-
- # -- :solar handle_async branches ----------------------------------
-
- test "solar handle_async populates Solar Conditions with real SFI/Ap/Kp", %{conn: conn} do
- # Pre-seed a SolarIndex row so load_solar/1 returns it; the async
- # clause keeps it and the render branch prints SFI + sunspot + Ap
- # + formatted Kp values.
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
-
- Repo.insert!(%SolarIndex{
- date: ~D[2026-03-28],
- sfi: 142.0,
- sunspot_number: 98,
- ap_index: 6,
- kp_values: [1.0, 2.0, 2.7, 3.0, 1.3, 0.7, 1.0, 1.3]
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "Solar Conditions"
- assert html =~ "142"
- assert html =~ "98"
- # Kp values render comma-joined via format_kp.
- assert html =~ "2.7"
- end
-
- test "solar handle_async for a missing date in an internal subnet enqueues a fetch",
- %{conn: conn} do
- # No SolarIndex row exists for the QSO date, remote_ip sits
- # inside the 172.56.0.0/13 enqueue subnet β enqueue_missing_solar
- # fires and @solar stays nil in the LV assigns.
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
- conn = %{conn | remote_ip: {172, 56, 0, 50}}
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- assigns = :sys.get_state(lv.pid).socket.assigns
- assert assigns.solar == nil
- # The LV stays alive after enqueueing.
- assert Process.alive?(lv.pid)
- end
-
- # -- compute_elevation_profile from sounding refractivity ----------
-
- test "soundings-array is rendered alongside HRRR profile when both are present",
- %{conn: conn} do
- # Sounding with surface_refractivity and ducting flags should
- # appear in the Soundings section; the duct carries into
- # extract_sounding_ducts when HRRR ducts are empty.
- contact =
- create_contact(%{
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- # HRRR profile without any duct characteristics.
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}],
- hpbl_m: 1100.0,
- pwat_mm: 24.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 12.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 322.0,
- min_refractivity_gradient: -90.0,
- ducting_detected: false
- })
-
- {:ok, raob_station} =
- Weather.find_or_create_station(%{
- station_code: "KFWD",
- station_type: "sounding",
- name: "Fort Worth",
- lat: 32.83,
- lon: -97.30
- })
-
- Repo.insert!(
- Sounding.changeset(%Sounding{}, %{
- station_id: raob_station.id,
- observed_at: ~U[2026-03-28 12:00:00Z],
- profile: [
- %{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 18.0, "hght" => 100.0},
- %{"pres" => 950.0, "tmpc" => 24.0, "dwpc" => 5.0, "hght" => 600.0}
- ],
- level_count: 2,
- surface_temp_c: 22.0,
- surface_dewpoint_c: 18.0,
- surface_refractivity: 345.0,
- ducting_detected: true,
- duct_characteristics: [%{"base" => 100.0, "top" => 400.0, "strength" => 8.0}]
- })
- )
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # HRRR still drives the atmospheric profile badge, but the
- # sounding row lands with its own surface refractivity value.
- assert html =~ "HRRR"
- assert html =~ "KFWD"
- # Sounding N=345 is surfaced by the sounding row (format_number β 345.0)
- assert html =~ "345"
- end
-
- # -- blocked_message with nil obs_dist via terrain-only cascade ----
-
- test "BLOCKED terrain without an elevation profile uses the generic obstructed copy",
- %{conn: conn} do
- # No elevation profile hydrated (ElevationClient stub returns []),
- # so obs_dist stays nil β blocked_message(nil, contact) fires.
- contact =
- create_contact(%{
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
- hpbl_m: 1200.0,
- pwat_mm: 22.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 10.0,
- surface_pressure_mb: 1010.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: -50.0,
- ducting_detected: false
- })
-
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
- max_elevation_m: 800.0,
- min_clearance_m: -10.0,
- diffraction_db: 10.0,
- fresnel_hit_count: 1,
- obstructed_count: 1,
- verdict: "BLOCKED"
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- # blocked_message(nil, _) = "Path is terrain-obstructed."
- assert html =~ "Path is terrain-obstructed"
- end
-
- # -- describe_verdict cascade via elevation_profile, not terrain ----
-
- test "FRESNEL_MINOR verdict via elevation profile drives the describe-verdict branch",
- %{conn: conn} do
- # With the SRTM stub populated, compute_elevation_profile returns
- # a real analysis whose verdict (CLEAR/FRESNEL_*/BLOCKED) drives
- # describe_verdict in terrain_summary. We assert the analysis
- # result makes it into the rendered summary.
- Req.Test.stub(ElevationClient, fn conn ->
- if conn.host == "api.open-meteo.com" do
- # Flat 250m profile β ensures CLEAR verdict, feeds
- # describe_verdict("CLEAR", β¦) via the elevation_profile.
- Req.Test.json(conn, %{"elevation" => List.duplicate(250.0, 257)})
- else
- Req.Test.json(conn, [])
- end
- end)
-
- contact =
- create_contact(%{
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 250.0}],
- max_elevation_m: 260.0,
- min_clearance_m: 30.0,
- diffraction_db: 0.0,
- fresnel_hit_count: 0,
- obstructed_count: 0,
- verdict: "CLEAR"
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # Elevation profile populated from the stub: verdict drives the
- # terrain_summary line (not terrain.verdict directly).
- ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile
- # When the stub propagates (see existing "elevation profile
- # hydrates when the SRTM stub returns a populated path" test),
- # ep is a map with :verdict; otherwise skip the assertion and
- # simply check the LV rendered without crashing.
- if ep do
- assert is_binary(ep.verdict)
- end
-
- assert render(lv) =~ "Propagation Mechanism"
- end
-
- # -- band_summary/2 branches ---------------------------------------
-
- test "2304 MHz band with elevated PWAT hits the 10 GHz band_summary branch",
- %{conn: conn} do
- # band_summary/2 clause for band_mhz <= 12_000 + pwat > 20.
- # 2304 MHz qualifies (<= 12_000) so the 10 GHz moisture copy
- # appears in the propagation analysis summary.
- contact =
- create_contact(%{
- band: Decimal.new("2304"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 18.0, "hght" => 100.0}],
- hpbl_m: 1100.0,
- pwat_mm: 32.0,
- surface_temp_c: 22.0,
- surface_dewpoint_c: 18.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 345.0,
- min_refractivity_gradient: -90.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "At 10 GHz, the elevated moisture"
- end
-
- test "47 GHz band with elevated PWAT hits the 24G-and-up absorption copy",
- %{conn: conn} do
- # band_mhz >= 24_000 clause β pwat > 25 triggers the absorption
- # summary line.
- contact =
- create_contact(%{
- band: Decimal.new("47000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}],
- hpbl_m: 1200.0,
- pwat_mm: 30.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 15.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 330.0,
- min_refractivity_gradient: -60.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "high moisture"
- assert html =~ "water vapor absorption"
- end
-
- test "144 MHz band falls through every band_summary clause (no moisture copy)",
- %{conn: conn} do
- # 144 MHz is <= 12_000 but has low PWAT β both band_summary
- # clauses return nil, so the propagation analysis summary is
- # built from terrain + mechanism only.
- contact =
- create_contact(%{
- band: Decimal.new("144"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 10.0, "dwpc" => -5.0, "hght" => 100.0}],
- hpbl_m: 800.0,
- pwat_mm: 8.0,
- surface_temp_c: 10.0,
- surface_dewpoint_c: -5.0,
- surface_pressure_mb: 1015.0,
- surface_refractivity: 290.0,
- min_refractivity_gradient: -30.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
- # No "10 GHz" or "GHz high moisture" narrative surfaces.
- refute html =~ "At 10 GHz, the elevated moisture"
- refute html =~ "water vapor absorption"
- # Band label renders in MHz for 144.
- assert html =~ "144 MHz"
- end
-
- test "1296 MHz band renders through the analysis without 10 GHz copy",
- %{conn: conn} do
- # 1296 MHz is <= 12_000 but low PWAT β no moisture narrative.
- contact =
- create_contact(%{
- band: Decimal.new("1296"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 10.0, "dwpc" => 0.0, "hght" => 100.0}],
- hpbl_m: 800.0,
- pwat_mm: 10.0,
- surface_temp_c: 10.0,
- surface_dewpoint_c: 0.0,
- surface_pressure_mb: 1015.0,
- surface_refractivity: 300.0,
- min_refractivity_gradient: -40.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "1296 MHz"
- refute html =~ "At 10 GHz, the elevated moisture"
- end
-
- test "75 GHz + low PWAT skips the moisture narrative", %{conn: conn} do
- # 75G >= 24_000 so enters that clause, but PWAT < 25 β nil.
- contact =
- create_contact(%{
- band: Decimal.new("75000"),
- pos1: %{"lat" => 32.9, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7}
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: 32.9,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 5.0, "dwpc" => -15.0, "hght" => 100.0}],
- hpbl_m: 700.0,
- pwat_mm: 5.0,
- surface_temp_c: 5.0,
- surface_dewpoint_c: -15.0,
- surface_pressure_mb: 1020.0,
- surface_refractivity: 280.0,
- min_refractivity_gradient: -30.0,
- ducting_detected: false
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- html = render_async(lv, 2_000)
-
- assert html =~ "75 GHz"
- refute html =~ "water vapor absorption"
- end
-
- # -- admin/owner edit paths --------------------------------------
-
- test "admin edit with proposed == %{} (no-op submit) surfaces 'No changes detected'",
- %{conn: conn} do
- # Admin submits the exact current values (no drift) β the
- # apply_admin_edit `proposed == %{}` branch fires explicitly.
- contact = create_contact()
- admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => contact.station1,
- "station2" => contact.station2,
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => contact.mode,
- "band" => Decimal.to_string(contact.band)
- }
- })
-
- assert html =~ "No changes detected"
- end
-
- test "admin edit changing mode to FT8 applies the update", %{conn: conn} do
- contact = create_contact(%{mode: "CW"})
- admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => contact.station1,
- "station2" => contact.station2,
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => "FT8"
- }
- })
-
- assert html =~ "Contact updated"
- assert Repo.get!(Contact, contact.id).mode == "FT8"
- end
-
- test "admin edit changing mode to SSB applies the update", %{conn: conn} do
- contact = create_contact(%{mode: "CW"})
- admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
- conn = log_in_user(conn, admin)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- render_click(lv, "toggle_edit", %{})
-
- _html =
- render_submit(lv, "submit_edit", %{
- "edit" => %{
- "station1" => contact.station1,
- "station2" => contact.station2,
- "grid1" => contact.grid1,
- "grid2" => contact.grid2,
- "mode" => "SSB"
- }
- })
-
- assert Repo.get!(Contact, contact.id).mode == "SSB"
- end
-
- # -- Obs-table sort handler: sort_observations over real rows ----
-
- test "sort handler returns a sorted list of the same length for a seeded obs set",
- %{conn: conn} do
- # Drive sort_observations via the "sort" event across every known
- # field. With four obs rows in the DB the sorted list length must
- # stay stable and the section header must not disappear.
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
-
- for code <- ~w(KDFW KDAL KAFW KGKY) do
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: code,
- station_type: "asos",
- name: "#{code} Airport",
- lat: 32.9,
- lon: -97.02
- })
-
- Repo.insert!(
- SurfaceObservation.changeset(%SurfaceObservation{}, %{
- station_id: station.id,
- observed_at: contact.qso_timestamp,
- temp_f: 60.0 + :rand.uniform() * 20,
- dewpoint_f: 40.0 + :rand.uniform() * 20,
- relative_humidity: 50.0,
- sea_level_pressure_mb: 1014.0
- })
- )
- end
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- before = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
-
- for f <-
- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb bogus_key) do
- html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
- assert html =~ "Surface Observations"
-
- after_count = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
- assert after_count == before, "row count changed after sorting by #{f}"
- end
- end
-
- # -- Queue counts + solar enqueue + pending_edit branches ---------
-
- test "fetch_queue_counts returns a map after seeding multiple distinct queues",
- %{conn: conn} do
- # Insert one job per queue β fetch_queue_counts groups by queue
- # and returns counts. The cache layer runs through fetch_or_store
- # (5s TTL) so we only assert map-shape + presence of known keys.
- alias Microwaveprop.Workers.SolarIndexWorker
- alias Microwaveprop.Workers.TerrainProfileWorker
-
- contact = create_contact()
-
- {:ok, _} =
- Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(~D[2026-03-28])}))
-
- {:ok, _} =
- Oban.insert(TerrainProfileWorker.new(%{"contact_id" => contact.id}))
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- counts = :sys.get_state(lv.pid).socket.assigns.queue_counts
-
- assert is_map(counts)
- # Counts map is populated (the cache may hold an earlier empty
- # read β the shape is what's under test here).
- for {k, v} <- counts do
- assert is_binary(k)
- assert is_integer(v) and v >= 0
- end
- end
-
- test "LV stays alive after render_async completes with no DB rows at all",
- %{conn: conn} do
- # A bare contact with no enrichment rows runs every hydration
- # task to completion, landing in the "default" branches of each
- # handle_async clause (empty hrrr_path, nil narr, nil terrain,
- # nil iemre, {nil, :unknown} radar). render_async must return
- # without the LV dying.
- contact = create_contact()
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- assigns = :sys.get_state(lv.pid).socket.assigns
- assert Process.alive?(lv.pid)
- assert MapSet.size(assigns.hydration_pending) == 0
- end
-
- # -- propagation_mechanism exhaustiveness property ----------------
-
- property "propagation_mechanism-derived summary stays a binary across random flag combos",
- %{conn: conn} do
- # Property: regardless of which combination of ducting / enhanced
- # refraction / blocked terrain applies, the propagation analysis
- # summary renders and the @propagation_analysis.summary stored in
- # socket.assigns is a string. This pins down propagation_mechanism
- # returning either a binary or nil (rejected by Enum.reject/2 in
- # build_summary), never a crash.
-
- bool_gen = StreamData.boolean()
-
- check all(
- ducting? <- bool_gen,
- enhanced? <- bool_gen,
- blocked? <- bool_gen,
- long_path? <- bool_gen,
- max_runs: 10
- ) do
- # Encode each flag into the DB row that drives the underlying
- # predicate (ducting_detected, min_refractivity_gradient,
- # terrain.verdict, contact.distance_km). The render then walks
- # blocked_mechanism/clear_path_mechanism down its cascades.
- dist = if long_path?, do: Decimal.new("200"), else: Decimal.new("50")
-
- # Per-iteration unique lat + valid_time so rows inserted by
- # earlier iterations don't collide on the hrrr_profiles
- # (lat, lon, valid_time) unique index. StreamData shrinks
- # toward degenerate values; System.unique_integer/1 guarantees
- # monotonically-advancing uniqueness across the whole run.
- nonce = System.unique_integer([:positive])
- lat1 = 32.0 + nonce / 1_000_000
- iter_valid_time = DateTime.add(~U[2026-03-28 18:00:00Z], nonce, :second)
-
- contact =
- create_contact(%{
- band: Decimal.new("10000"),
- qso_timestamp: iter_valid_time,
- pos1: %{"lat" => lat1, "lon" => -97.0},
- pos2: %{"lat" => 30.3, "lon" => -97.7},
- distance_km: dist
- })
-
- Repo.insert!(%HrrrProfile{
- valid_time: contact.qso_timestamp,
- lat: lat1,
- lon: -97.0,
- profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}],
- hpbl_m: 1200.0,
- pwat_mm: 22.0,
- surface_temp_c: 20.0,
- surface_dewpoint_c: 12.0,
- surface_pressure_mb: 1012.0,
- surface_refractivity: 320.0,
- min_refractivity_gradient: if(enhanced?, do: -150.0, else: -40.0),
- ducting_detected: ducting?
- })
-
- Repo.insert!(%TerrainProfile{
- contact_id: contact.id,
- sample_count: 100,
- path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
- max_elevation_m: if(blocked?, do: 800.0, else: 400.0),
- min_clearance_m: if(blocked?, do: -20.0, else: 40.0),
- diffraction_db: if(blocked?, do: 15.0, else: 0.0),
- fresnel_hit_count: 0,
- obstructed_count: if(blocked?, do: 2, else: 0),
- verdict: if(blocked?, do: "BLOCKED", else: "CLEAR")
- })
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- analysis = :sys.get_state(lv.pid).socket.assigns.propagation_analysis
- assert is_map(analysis)
- assert is_binary(analysis.summary)
- # details is a list of strings (or empty) β no nils slipped through.
- assert is_list(analysis.details)
- assert Enum.all?(analysis.details, &is_binary/1)
- end
- end
-
- # -- obs_sort_key totality property -----------------------------
-
- property "sort-observations handler keeps the assign as a list of stable length",
- %{conn: conn} do
- # For any sequence of known or unknown sort-field strings, the
- # resulting surface_observations assign is a list whose length
- # matches the initial count. Covers obs_sort_key's fall-through
- # clause on any atom/string the caller throws at it.
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
-
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: "KDFW",
- station_type: "asos",
- name: "DFW Airport",
- lat: 32.9,
- lon: -97.02
- })
-
- for i <- 1..3 do
- Repo.insert!(
- SurfaceObservation.changeset(%SurfaceObservation{}, %{
- station_id: station.id,
- observed_at: DateTime.add(contact.qso_timestamp, i * 60, :second),
- temp_f: 60.0 + i,
- dewpoint_f: 45.0 + i,
- relative_humidity: 55.0,
- sea_level_pressure_mb: 1014.0
- })
- )
- end
-
- # Any ASCII string (known or not) is a valid sort-field. The
- # handler must never crash the LV and must preserve row count.
- gen_field =
- StreamData.one_of([
- StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)),
- StreamData.string(:alphanumeric, min_length: 1, max_length: 8)
- ])
-
- check all(fields <- StreamData.list_of(gen_field, min_length: 1, max_length: 5), max_runs: 10) do
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- baseline = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
-
- for f <- fields do
- render_click(lv, "sort", %{"field" => f, "table" => "obs"})
- obs = :sys.get_state(lv.pid).socket.assigns.surface_observations
- assert is_list(obs)
- assert length(obs) == baseline, "sorting by #{inspect(f)} changed row count"
- end
- end
- end
- end
-
- describe "Show ordering and proximity helpers" do
- # Exercises sort_observations/3, sort_soundings/3, and
- # closest_observations/4 on *populated* rows so the actual field
- # ordering is asserted β the previous sort-handler tests only
- # asserted that rendering stayed alive.
-
- use ExUnitProperties
-
- alias Microwaveprop.Geo
- alias Microwaveprop.Weather
- alias Microwaveprop.Weather.Sounding
- alias Microwaveprop.Weather.SurfaceObservation
-
- defp seed_distinct_obs(contact) do
- # Four distinct surface observations with strictly-increasing
- # temp_f, dewpoint_f, relative_humidity, sea_level_pressure_mb,
- # and observed_at, keyed by station code for station_name order.
- Enum.map(
- [
- {"KAAA", 32.90, -97.00, 60.0, 40.0, 50.0, 1010.0, 0},
- {"KBBB", 32.91, -97.01, 62.0, 42.0, 52.0, 1011.0, 60},
- {"KCCC", 32.92, -97.02, 64.0, 44.0, 54.0, 1012.0, 120},
- {"KDDD", 32.93, -97.03, 66.0, 46.0, 56.0, 1013.0, 180}
- ],
- fn {code, lat, lon, temp, dewp, rh, pres, offset} ->
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: code,
- station_type: "asos",
- name: "#{code} Airport",
- lat: lat,
- lon: lon
- })
-
- Repo.insert!(
- SurfaceObservation.changeset(%SurfaceObservation{}, %{
- station_id: station.id,
- observed_at: DateTime.add(contact.qso_timestamp, offset, :second),
- temp_f: temp,
- dewpoint_f: dewp,
- relative_humidity: rh,
- sea_level_pressure_mb: pres
- })
- )
-
- code
- end
- )
- end
-
- test "sort_observations orders by station_name ascending and descending", %{conn: conn} do
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
- codes = seed_distinct_obs(contact)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # Prime with observed_at so a subsequent station_name click hits
- # the "new field β asc" branch of toggle_sort/3.
- _ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"})
-
- # First station_name click β asc.
- _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"})
-
- asc_codes =
- lv.pid
- |> :sys.get_state()
- |> Map.fetch!(:socket)
- |> Map.fetch!(:assigns)
- |> Map.fetch!(:surface_observations)
- |> Enum.map(& &1.station.station_code)
-
- assert asc_codes == Enum.sort(codes)
-
- # Second station_name click β desc.
- _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"})
-
- desc_codes =
- lv.pid
- |> :sys.get_state()
- |> Map.fetch!(:socket)
- |> Map.fetch!(:assigns)
- |> Map.fetch!(:surface_observations)
- |> Enum.map(& &1.station.station_code)
-
- assert desc_codes == Enum.sort(codes, :desc)
- end
-
- test "sort_observations orders by observed_at, temp_f, dewpoint_f, relative_humidity, and pressure",
- %{conn: conn} do
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
- _ = seed_distinct_obs(contact)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- check_field = fn field, reader ->
- _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
-
- obs =
- lv.pid
- |> :sys.get_state()
- |> Map.fetch!(:socket)
- |> Map.fetch!(:assigns)
- |> Map.fetch!(:surface_observations)
-
- values = Enum.map(obs, reader)
-
- assert values == Enum.sort(values),
- "obs not ascending on #{field}: #{inspect(values)}"
- end
-
- check_field.("observed_at", & &1.observed_at)
- check_field.("temp_f", & &1.temp_f)
- check_field.("dewpoint_f", & &1.dewpoint_f)
- check_field.("relative_humidity", & &1.relative_humidity)
- check_field.("sea_level_pressure_mb", & &1.sea_level_pressure_mb)
- end
-
- defp seed_distinct_soundings(contact) do
- # Three soundings with distinct surface_temp_c, surface_refractivity,
- # k_index, and lifted_index to check every sounding_sort_key branch.
- Enum.map(
- [
- {"KAAAS", 15.0, 310.0, 20.0, -2.0, 0},
- {"KBBBS", 18.0, 315.0, 25.0, -1.0, 3600},
- {"KCCCS", 21.0, 320.0, 30.0, 0.0, 7200}
- ],
- fn {code, temp, refr, k, li, offset} ->
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: code,
- station_type: "sounding",
- name: "#{code} Sounding",
- lat: 32.9 + :rand.uniform() * 0.001,
- lon: -97.0 + :rand.uniform() * 0.001
- })
-
- Repo.insert!(
- Sounding.changeset(%Sounding{}, %{
- station_id: station.id,
- observed_at: DateTime.add(contact.qso_timestamp, offset, :second),
- profile: [%{"pres" => 1000.0, "tmpc" => temp, "dwpc" => temp - 5.0, "hght" => 100.0}],
- level_count: 1,
- surface_temp_c: temp,
- surface_dewpoint_c: temp - 5.0,
- surface_refractivity: refr,
- k_index: k,
- lifted_index: li
- })
- )
-
- code
- end
- )
- end
-
- test "sort_soundings orders by every known sounding field", %{conn: conn} do
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
- _ = seed_distinct_soundings(contact)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # Each click on a new field toggles to that field asc (new-field
- # branch of toggle_sort). Prime with observed_at so the later
- # station_name click also hits the new-field branch.
- _ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"})
- _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "soundings"})
-
- station_names =
- lv.pid
- |> :sys.get_state()
- |> Map.fetch!(:socket)
- |> Map.fetch!(:assigns)
- |> Map.fetch!(:soundings)
- |> Enum.map(&(&1.station.name || &1.station.station_code))
-
- assert station_names == Enum.sort(station_names)
-
- check_field = fn field, reader ->
- _ = render_click(lv, "sort", %{"field" => field, "table" => "soundings"})
-
- rows =
- lv.pid
- |> :sys.get_state()
- |> Map.fetch!(:socket)
- |> Map.fetch!(:assigns)
- |> Map.fetch!(:soundings)
-
- values = Enum.map(rows, reader)
-
- assert values == Enum.sort(values),
- "soundings not ascending on #{field}: #{inspect(values)}"
- end
-
- check_field.("observed_at", & &1.observed_at)
- check_field.("surface_temp_c", & &1.surface_temp_c)
- check_field.("surface_refractivity", & &1.surface_refractivity)
- check_field.("k_index", & &1.k_index)
- check_field.("lifted_index", & &1.lifted_index)
- end
-
- test "closest_observations/4 keeps only the five nearest by midpoint distance", %{conn: conn} do
- # Seed seven surface observations spread along the path corridor;
- # closest_observations trims to the top five by |dlat|+|dlon| from
- # the midpoint. The test asserts (a) the assign length β€ 5 and
- # (b) no station sits at a larger distance than the farthest-kept.
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
- mid_lat = (32.9 + 30.3) / 2
- mid_lon = (-97.0 + -97.7) / 2
-
- near_to_far = [
- {"KMID0", mid_lat, mid_lon},
- {"KMID1", mid_lat + 0.05, mid_lon + 0.05},
- {"KMID2", mid_lat - 0.05, mid_lon - 0.05},
- {"KMID3", mid_lat + 0.10, mid_lon + 0.10},
- {"KMID4", mid_lat - 0.10, mid_lon - 0.10},
- {"KMID5", mid_lat + 0.20, mid_lon + 0.20},
- {"KMID6", mid_lat - 0.20, mid_lon - 0.20}
- ]
-
- _ =
- Enum.map(near_to_far, fn {code, lat, lon} ->
- {:ok, station} =
- Weather.find_or_create_station(%{
- station_code: code,
- station_type: "asos",
- name: "#{code}",
- lat: lat,
- lon: lon
- })
-
- Repo.insert!(
- SurfaceObservation.changeset(%SurfaceObservation{}, %{
- station_id: station.id,
- observed_at: contact.qso_timestamp,
- temp_f: 70.0,
- dewpoint_f: 55.0,
- relative_humidity: 55.0,
- sea_level_pressure_mb: 1015.0
- })
- )
- end)
-
- {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- obs =
- lv.pid
- |> :sys.get_state()
- |> Map.fetch!(:socket)
- |> Map.fetch!(:assigns)
- |> Map.fetch!(:surface_observations)
-
- # closest_observations caps the list at 5.
- assert length(obs) <= 5
-
- # Every kept station must be at least as close as the farthest one.
- distances =
- Enum.map(obs, fn o ->
- abs(o.station.lat - mid_lat) + abs(o.station.lon - mid_lon)
- end)
-
- assert distances == Enum.sort(distances)
- end
-
- test "contact with pos1 but nil pos2 renders without blowing up (half_dist = 0 path)",
- %{conn: conn} do
- # Forcing pos2 to nil via a bare Ecto.Changeset.change bypasses the
- # Contact.changeset validation that ordinarily fills it in. This
- # exercises the `if lat2 && lon2` guards in fetch_weather_for_path.
- contact = create_contact()
-
- {:ok, contact} =
- contact
- |> Ecto.Changeset.change(pos2: nil, grid2: nil, distance_km: nil)
- |> Repo.update()
-
- assert is_nil(contact.pos2)
-
- {:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- # The detail page renders with station1 present β the render
- # pipeline walked through fetch_weather_for_path with lat2=nil,
- # mid_lat=lat1, half_dist=0.
- assert html =~ "W5XD"
- assert Process.alive?(lv.pid)
- end
-
- property "sort_observations/3 preserves list length + element set for any field key",
- %{conn: conn} do
- # Round-tripping the `sort` event across any mix of known and
- # unknown field keys must leave the surface_observations assign
- # with the same cardinality AND the same set of station codes.
- contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
- codes = seed_distinct_obs(contact)
-
- fields_gen =
- StreamData.one_of([
- StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)),
- StreamData.string(:alphanumeric, min_length: 1, max_length: 6)
- ])
-
- check all(fields <- StreamData.list_of(fields_gen, min_length: 1, max_length: 5), max_runs: 10) do
- {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
- _ = render_async(lv, 2_000)
-
- for f <- fields do
- _ = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
-
- obs =
- lv.pid
- |> :sys.get_state()
- |> Map.fetch!(:socket)
- |> Map.fetch!(:assigns)
- |> Map.fetch!(:surface_observations)
-
- assert length(obs) == length(codes)
- assert Enum.sort(Enum.map(obs, & &1.station.station_code)) == Enum.sort(codes)
- end
- end
- end
-
- property "haversine_km/4 is symmetric: d(a,b,c,d) == d(c,d,a,b)" do
- # Uses Microwaveprop.Geo.haversine_km/4 β the public, structurally
- # identical twin of the private haversine_km in ContactLive.Show.
- # Symmetry under endpoint swap is the load-bearing invariant the
- # show-page midpoint + half-distance logic relies on.
- check all(
- lat1 <- StreamData.float(min: -89.0, max: 89.0),
- lon1 <- StreamData.float(min: -179.0, max: 179.0),
- lat2 <- StreamData.float(min: -89.0, max: 89.0),
- lon2 <- StreamData.float(min: -179.0, max: 179.0)
- ) do
- ab = Geo.haversine_km(lat1, lon1, lat2, lon2)
- ba = Geo.haversine_km(lat2, lon2, lat1, lon1)
- assert_in_delta ab, ba, 1.0e-6
- end
- end
- end
-end
diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs
index aaff6d6e..6ae82546 100644
--- a/test/microwaveprop_web/live/map_live_test.exs
+++ b/test/microwaveprop_web/live/map_live_test.exs
@@ -219,26 +219,26 @@ defmodule MicrowavepropWeb.MapLiveTest do
# 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)
+ assert render(lv) =~ "propagation-map"
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)
+ assert render(lv) =~ "propagation-map"
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)
+ assert render(lv) =~ "propagation-map"
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)
+ assert render(lv) =~ "propagation-map"
end
test "select_time with valid iso updates selected_time assign", %{conn: conn} do
@@ -272,7 +272,6 @@ defmodule MicrowavepropWeb.MapLiveTest do
# 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
diff --git a/test/microwaveprop_web/live/rover_live_test.exs b/test/microwaveprop_web/live/rover_live_test.exs
index 3d77abe9..f5ac2c1d 100644
--- a/test/microwaveprop_web/live/rover_live_test.exs
+++ b/test/microwaveprop_web/live/rover_live_test.exs
@@ -110,10 +110,8 @@ defmodule MicrowavepropWeb.RoverLiveTest do
{:ok, view, _html} = live(conn, ~p"/rover")
send(view.pid, {:rover_progress, "Scoring band 10G", 5, 18})
+ _ = :sys.get_state(view.pid)
- Process.sleep(20)
- # Just verify the LV is alive β render reflects scoring_step but
- # we don't assert on the exact text.
assert render(view) =~ "Rover planning"
end
end
diff --git a/test/microwaveprop_web/live/submit_live_test.exs b/test/microwaveprop_web/live/submit_live_test.exs
index 1aada6f4..27ac3d6c 100644
--- a/test/microwaveprop_web/live/submit_live_test.exs
+++ b/test/microwaveprop_web/live/submit_live_test.exs
@@ -52,7 +52,7 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
# and the process to stay alive.
render_hook(lv, "switch_tab", %{"tab" => "definitely-not-a-tab-#{System.unique_integer()}"})
- assert Process.alive?(lv.pid)
+ assert render(lv) =~ "Submit Contact"
end
test "honors a known tab name", %{conn: conn} do
diff --git a/test/microwaveprop_web/telemetry_test.exs b/test/microwaveprop_web/telemetry_test.exs
index 7036c49d..c6b51995 100644
--- a/test/microwaveprop_web/telemetry_test.exs
+++ b/test/microwaveprop_web/telemetry_test.exs
@@ -91,7 +91,7 @@ defmodule MicrowavepropWeb.TelemetryTest do
sup_name = :"telemetry_under_test_#{System.unique_integer([:positive])}"
{:ok, pid} = Supervisor.start_link(Telemetry, :ok, name: sup_name)
- assert Process.alive?(pid)
+ assert Process.whereis(sup_name) == pid
# Unlink before tearing down so the brutal-kill exit signal doesn't
# propagate to the test process (which trap_exit defaults to off).