- 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)
1759 lines
55 KiB
Elixir
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
|