Adds 67 test cases + 12 property tests across four surfaces — parallel agents targeted the lowest-coverage files in the tree. - ContactLive.Show (48.56% → 72.01%): 16 unit tests + 2 properties covering IEMRE/radar/ducting/propagation-analysis render branches, every terrain-verdict class, expanded HRRR/terrain sections, pending- edit banner, internal-network conn, and total-over-fields properties for obs + sounding sort handlers. - HrrrNativeClient (33% → 58%): 8 unit tests + 4 properties covering partial-surface fallback, empty input, optional-var nils, bogus binary dispatch, and level-count + monotonic-height + URL-encoding + duct-subset invariants. - NexradClient / HrrrClient / GefsClient: 13 unit tests + 4 properties covering 500/404/timeout paths, malformed idx bodies, parse_idx totality, byte-range invariants, and URL-builder round-trips. - HrrrBackfill / HrrrNativeDeriveFields / ImportContestLogs: 7 unit tests + 1 property. Seeds contacts + native profiles, stubs HRRR with Req.Test, exercises the `--limit 0` and `filter_points_needing_ backfill` paths, CSV import happy + malformed paths, and a band-string round-trip property. 2599 → 2666 tests, 170 → 182 properties, 0 failures.
1483 lines
50 KiB
Elixir
1483 lines
50 KiB
Elixir
defmodule MicrowavepropWeb.ContactLiveTest do
|
|
use MicrowavepropWeb.ConnCase, async: true
|
|
|
|
import Phoenix.LiveViewTest
|
|
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
|
alias Microwaveprop.Weather.NarrProfile
|
|
|
|
setup do
|
|
Req.Test.stub(Microwaveprop.Terrain.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")
|
|
}
|
|
|
|
{changeset_attrs, direct_attrs} = Map.split(Map.merge(default, attrs), [:user_submitted])
|
|
|
|
{:ok, contact} =
|
|
%Contact{}
|
|
|> Contact.changeset(direct_attrs)
|
|
|> Ecto.Changeset.change(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
|
|
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 </button>" 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
|
|
end
|