prop/test/microwaveprop_web/live/contact_live/show_coverage_test.exs
Graham McIntire cc3dc41a6b
Fix all medium findings and split contact_live_test.exs
- Fix N+1 queries in radio.ex (preload :user)
- Add encryption_salt to session options
- Consolidate token deletion to single query
- Wrap gunzip in try/rescue for corrupt files
- Add Cache.match_delete/1 to encapsulate ETS access
- Precompute sec_i_factor_ref as module attribute
- Guard EXLA.Backend config to dev/test only
- Split 3505-line contact_live_test.exs into 4 files
- Replace Process.sleep with :sys.get_state synchronization
- Replace Process.alive? with DOM output assertions
- Clarify router.ex comment for non-live_session routes
- Update findings.md to reflect fixes
2026-05-29 15:53:04 -05:00

1809 lines
57 KiB
Elixir

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