prop/test/microwaveprop_web/live/contact_live/show_coverage_test.exs
Graham McIntire 0d00d1777c
fix: 9 bugs across tests and production code
- Fix Module.safe_concat -> Module.concat in tests with dynamic process names
  (safe_concat calls binary_to_existing_atom, but test names are newly generated)
- Fix PSKR AggregatorTest sandbox ownership by switching to async: false
- Fix MapLiveTest assert_patch: regex unsupported in LiveView 1.2, use string match
- Fix WeatherMapLiveTest toggle_grid: assert on checked attribute, not data-grid
- Fix BeaconLive.Index leaking unapproved beacons: wire data_provider to approved_beacons_query
- Fix ContactLive.Index leaking private contacts: wire data_provider with visibility filter
- Fix RecalibratorTest: train() expects factor vectors, not {vector, datetime, band} tuples
- Fix toggle_sort: compare current_field to new_field, not current_order
- Fix internal_network?: handle both atom and string session keys from Plug sessions

Test results: 3979/4001 -> 3997/4001 (18 previously-failing tests now pass)
2026-06-21 12:13:58 -05:00

1759 lines
55 KiB
Elixir

defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do
use MicrowavepropWeb.ConnCase, async: true
import Microwaveprop.AccountsFixtures
import Phoenix.LiveViewTest
alias Microwaveprop.Geo
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Propagation.Scorer
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactCommonVolumeRadar
alias Microwaveprop.Radio.ContactEdit
alias Microwaveprop.Repo
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainProfile
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.NarrProfile
alias Microwaveprop.Weather.SolarIndex
alias Microwaveprop.Weather.Sounding
alias Microwaveprop.Weather.SurfaceObservation
alias Microwaveprop.Workers.SolarIndexWorker
alias Microwaveprop.Workers.TerrainProfileWorker
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
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 String.match?(html, ~r/(tropospheric duct|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 String.match?(html, ~r/(extended range|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}")
html = render_async(lv, 2_000)
assert html =~ "No surface observations found nearby."
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}")
html = render_async(lv, 2_000)
assert html =~ "No surface observations found nearby."
end
test "internal_network? true when session IP sits inside the enqueue subnet", %{conn: conn} do
conn = Phoenix.ConnTest.init_test_session(conn, %{remote_ip: "172.56.0.10"})
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Fetching surface observations"
end
test "expanded_soundings MapSet exposes the skew-T table when a sounding is expanded",
%{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
})
{: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}")
html = render(lv)
assert html =~ "You have a pending edit for this contact"
end
test "fetch_queue_counts hits the Cache.fetch_or_store round trip", %{conn: conn} do
{:ok, _job} = Oban.insert(SolarIndexWorker.new(%{"date" => "2026-03-28"}))
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render(lv)
assert html =~ "in queue"
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}")
html_before = render_async(lv, 2_000)
send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}})
html_after = render(lv)
assert html_before == html_after
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}")
html = render_async(lv, 2_000)
assert html =~ "Az:"
assert html =~ "El:"
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 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"})
_ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
_ = render_click(lv, "sort", %{"field" => field, "table" => "obs"})
assert render(lv) =~ "Surface Observations"
end
end
end
describe "Show failure-state + enrichment-combo render branches" do
use ExUnitProperties
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}")
html = render_async(lv, 2_000)
assert html =~ "Fetching solar index data"
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}")
html = render_async(lv, 2_000)
assert html =~ "Az:"
assert html =~ "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}")
html = render_async(lv, 2_000)
assert html =~ "4 stations"
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"
assert html =~ "4 stations"
end
end
test "fetch_queue_counts returns a map after seeding multiple distinct queues",
%{conn: conn} do
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}")
html = render(lv)
assert html =~ "in queue"
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}")
html = render_async(lv, 2_000)
assert html =~ "No surface observations found nearby."
assert Process.alive?(lv.pid)
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}")
html = render_async(lv, 2_000)
assert html =~ "Propagation Analysis"
assert html =~ "/100"
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}")
html = render_async(lv, 2_000)
assert html =~ "3 stations"
for f <- fields do
html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
assert html =~ "3 stations"
end
end
end
end
describe "Show ordering and proximity helpers" do
use ExUnitProperties
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