test: coverage round 3 (83.96% → 84.39%) + 10 new property tests
64 unit tests + 10 property tests across three parallel agents. - ContactLive.Show round 3 (72% → ~80%): enrichment failure surfaces (terrain/iemre/weather :failed, hrrr :unavailable NARR fallback), radar-only mechanism, load_solar + enqueue_missing_solar paths, blocked_message nil obs_dist, band_summary for 144M/1296M/ 2304M/47G/75G, admin edit no-op, mode changes, fetch_queue_counts with seeded Oban jobs. Properties: propagation_mechanism summary is always a binary, obs_sort_key totality over arbitrary field keys. - Weather clients: iem_client transport timeouts + CSV edge cases, rtma_client idx url property, swpc_client HTTP 503/404 + empty- array parses + JSON/CSV totality properties, ncei_metar_client parse edge cases, snmp_client OID-roundtrip property + parse edge cases. - Mid-coverage LiveViews: UserProfileLive avatar/render branches, EmeLive invalid-grid/unknown-callsign + form-validation property, PathLive zero-distance + 3000km + height/power round-trip property, BeaconLive numeric bearing + non-admin approve denied, MapLive select_band/toggle_radar/toggle_grid events + band-URL property. Fix: propagation_mechanism property test uses System.unique_integer for lat + valid_time nonces instead of StreamData-shrinkable jitter; the shrink toward 0 was tripping the hrrr_profiles unique index. 205 → 215 properties, 2743 → 2812 tests, 0 failures.
This commit is contained in:
parent
bbf51544e1
commit
c514626a62
11 changed files with 1571 additions and 0 deletions
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Microwaveprop.Commercial.SnmpClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Commercial.SnmpClient
|
||||
|
||||
|
|
@ -181,4 +182,107 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_snmpget_output/2 additional edge cases" do
|
||||
test "lines without the ' = ' separator are skipped" do
|
||||
# parse_snmp_line needs `OID = TYPE: value`. Any line that doesn't
|
||||
# split on ' = ' into two parts is silently dropped.
|
||||
output = """
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.11.1 = INTEGER: -60
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.14.1: INTEGER -55
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_snmpget_output(output, :af11x)
|
||||
assert result.rx_power_0 == -60
|
||||
refute Map.has_key?(result, :rx_power_1)
|
||||
end
|
||||
|
||||
test "quoted STRING values are stripped of quotes; non-numeric content is dropped" do
|
||||
output = """
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.11.1 = STRING: "-58"
|
||||
iso.3.6.1.4.1.41112.1.3.2.1.14.1 = STRING: "abc"
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_snmpget_output(output, :af11x)
|
||||
# Quoted integer survives via Integer.parse's "succeed with trailing".
|
||||
assert result.rx_power_0 == -58
|
||||
# Non-numeric string doesn't parse → dropped.
|
||||
refute Map.has_key?(result, :rx_power_1)
|
||||
end
|
||||
|
||||
test "leading-dot OID prefix is normalised before the AF11X lookup" do
|
||||
# `normalize_oid` strips a leading `.` and `iso.` so the fully-
|
||||
# qualified form with a leading dot still matches.
|
||||
output = ".1.3.6.1.4.1.41112.1.3.1.1.9.1 = INTEGER: 19\n"
|
||||
|
||||
result = SnmpClient.parse_snmpget_output(output, :af11x)
|
||||
assert result.tx_power == 19
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_af60_output/2 additional edge cases" do
|
||||
test "static-only input yields a map with just the static fields" do
|
||||
static = """
|
||||
iso.3.6.1.4.1.41112.1.11.1.2.5.1 = INTEGER: 41
|
||||
iso.3.6.1.4.1.41112.1.11.1.2.6.1 = INTEGER: 0
|
||||
iso.3.6.1.4.1.41112.1.11.1.1.2.1 = INTEGER: 60160
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_af60_output(static, "")
|
||||
assert result.radio_temp_0_c == 41
|
||||
assert result.link_state == 0
|
||||
assert result.tx_freq_mhz == 60_160
|
||||
# No station data at all.
|
||||
refute Map.has_key?(result, :rx_power_0)
|
||||
end
|
||||
|
||||
test "station-only input (no static) still produces the station fields" do
|
||||
station = """
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.3.1.2.3.4.5.6 = INTEGER: -70
|
||||
iso.3.6.1.4.1.41112.1.11.1.3.1.4.1.2.3.4.5.6 = INTEGER: 12
|
||||
"""
|
||||
|
||||
result = SnmpClient.parse_af60_output("", station)
|
||||
assert result.rx_power_0 == -70
|
||||
assert result.tx_power == 12
|
||||
refute Map.has_key?(result, :radio_temp_0_c)
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_snmpget_output/2 property" do
|
||||
# Pick any AF11X integer value and any recognised AF11X OID suffix, render
|
||||
# the canonical `iso.<oid> = INTEGER: <value>` line, and assert the value
|
||||
# round-trips through parse_snmpget_output unmodified.
|
||||
@af11x_oid_to_field %{
|
||||
"1.3.6.1.4.1.41112.1.3.2.1.11.1" => :rx_power_0,
|
||||
"1.3.6.1.4.1.41112.1.3.2.1.14.1" => :rx_power_1,
|
||||
"1.3.6.1.4.1.41112.1.3.1.1.9.1" => :tx_power,
|
||||
"1.3.6.1.4.1.41112.1.3.2.1.26.1" => :link_state,
|
||||
"1.3.6.1.4.1.41112.1.3.2.1.44.1" => :link_uptime,
|
||||
"1.3.6.1.4.1.41112.1.3.1.1.5.1" => :tx_freq_mhz,
|
||||
"1.3.6.1.4.1.41112.1.3.2.1.4.1" => :link_distance_m
|
||||
}
|
||||
|
||||
property "rendered snmpget lines round-trip through parse_snmpget_output" do
|
||||
oids = Map.keys(@af11x_oid_to_field)
|
||||
|
||||
check all(
|
||||
oid <- member_of(oids),
|
||||
value <- integer(-2_000_000..2_000_000)
|
||||
) do
|
||||
# snmpget's textual form renders `iso.` as the stand-in for `.1`
|
||||
# (OID root `.1`, not `.1.3.6.1`), so we render the OID with the
|
||||
# leading `1.` stripped and a literal `iso.` prefix. The client's
|
||||
# `normalize_oid` puts it back to the dotted numeric form.
|
||||
"1." <> tail = oid
|
||||
line = "iso.#{tail} = INTEGER: #{value}"
|
||||
field = Map.fetch!(@af11x_oid_to_field, oid)
|
||||
result = SnmpClient.parse_snmpget_output(line, :af11x)
|
||||
assert Map.fetch!(result, field) == value
|
||||
# And the OID string we hand-built starts with the UBNT
|
||||
# enterprise prefix.
|
||||
assert String.starts_with?(oid, "1.3.6.1.4.1.41112")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Microwaveprop.SpaceWeather.SwpcClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.SpaceWeather.SwpcClient
|
||||
|
||||
|
|
@ -204,5 +205,77 @@ defmodule Microwaveprop.SpaceWeather.SwpcClientTest do
|
|||
assert {:error, :not_a_list} = SwpcClient.parse_f107(~s({"not":"list"}))
|
||||
assert {:error, :not_a_list} = SwpcClient.parse_xrays(~s({"not":"list"}))
|
||||
end
|
||||
|
||||
test "parse_kp returns {:ok, []} for an empty JSON array" do
|
||||
assert {:ok, []} = SwpcClient.parse_kp("[]")
|
||||
end
|
||||
|
||||
test "parse_f107 returns {:ok, []} for an empty JSON array" do
|
||||
assert {:ok, []} = SwpcClient.parse_f107("[]")
|
||||
end
|
||||
|
||||
test "parse_xrays returns {:ok, []} for an empty JSON array" do
|
||||
assert {:ok, []} = SwpcClient.parse_xrays("[]")
|
||||
end
|
||||
|
||||
test "parse_* reject bare non-list non-string inputs" do
|
||||
assert {:error, :not_a_list} = SwpcClient.parse_kp(nil)
|
||||
assert {:error, :not_a_list} = SwpcClient.parse_kp(123)
|
||||
assert {:error, :not_a_list} = SwpcClient.parse_f107(:an_atom)
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_* totality property" do
|
||||
property "parse_kp/1 never raises on arbitrary printable strings" do
|
||||
# Malformed JSON must surface as a tagged {:ok, _} / {:error, _}
|
||||
# tuple — the caller logs and moves on. A crash would kill the
|
||||
# worker and blow past its retry budget.
|
||||
check all(body <- string(:printable, max_length: 120)) do
|
||||
result = SwpcClient.parse_kp(body)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
end
|
||||
|
||||
property "parse_xrays/1 never raises on arbitrary printable strings" do
|
||||
check all(body <- string(:printable, max_length: 120)) do
|
||||
result = SwpcClient.parse_xrays(body)
|
||||
assert match?({:ok, _}, result) or match?({:error, _}, result)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_kp/0 HTTP errors" do
|
||||
test "non-200 surfaces as {:error, 'SWPC HTTP ...'}" do
|
||||
Req.Test.stub(SwpcClient, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("text/plain")
|
||||
|> Plug.Conn.send_resp(503, "unavailable")
|
||||
end)
|
||||
|
||||
assert {:error, reason} = SwpcClient.fetch_kp()
|
||||
assert reason =~ "SWPC HTTP 503"
|
||||
end
|
||||
|
||||
test "transport timeout surfaces as {:error, 'SWPC request failed: ...'}" do
|
||||
Req.Test.stub(SwpcClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, reason} = SwpcClient.fetch_kp()
|
||||
assert reason =~ "SWPC request failed"
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_f107/0 HTTP errors" do
|
||||
test "404 on the f107 endpoint is surfaced verbatim" do
|
||||
Req.Test.stub(SwpcClient, fn conn ->
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("text/plain")
|
||||
|> Plug.Conn.send_resp(404, "gone")
|
||||
end)
|
||||
|
||||
assert {:error, reason} = SwpcClient.fetch_f107()
|
||||
assert reason =~ "SWPC HTTP 404"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Microwaveprop.Weather.IemClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Weather.IemClient
|
||||
|
||||
|
|
@ -401,5 +402,109 @@ defmodule Microwaveprop.Weather.IemClientTest do
|
|||
|
||||
assert {:error, "IEM IEMRE HTTP 404"} = IemClient.fetch_iemre(32.875, -97.0, ~D[2026-03-28])
|
||||
end
|
||||
|
||||
test "bubbles up a transport-level timeout as {:error, %TransportError{}}" do
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, reason} = IemClient.fetch_iemre(32.875, -97.0, ~D[2026-03-28])
|
||||
# Req surfaces a %Req.TransportError{reason: :timeout}, not a string.
|
||||
refute is_binary(reason)
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_network/1 transport errors" do
|
||||
test "bubbles a transport timeout up as {:error, _}" do
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, _reason} = IemClient.fetch_network("NY_ASOS")
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_asos/3 transport errors" do
|
||||
test "bubbles a transport timeout up as {:error, _}" do
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, _reason} =
|
||||
IemClient.fetch_asos("KDFW", ~U[2026-03-28 16:00:00Z], ~U[2026-03-28 20:00:00Z])
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_raob/2 transport errors" do
|
||||
test "bubbles a transport timeout up as {:error, _}" do
|
||||
Req.Test.stub(IemClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, _reason} = IemClient.fetch_raob("FWD", ~U[2026-03-28 12:00:00Z])
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_asos_csv/1 edge cases" do
|
||||
test "returns [] for a CSV with only the header row" do
|
||||
csv = "station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes\n"
|
||||
assert IemClient.parse_asos_csv(csv) == []
|
||||
end
|
||||
|
||||
test "handles a CSV with trailing newline without crashing" do
|
||||
csv = """
|
||||
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
||||
KDFW,2026-03-28 18:53,75.0,55.0,49.12,12,180,1013.2,29.92,SCT,0.03,RA
|
||||
|
||||
"""
|
||||
|
||||
rows = IemClient.parse_asos_csv(csv)
|
||||
assert length(rows) == 1
|
||||
end
|
||||
|
||||
test "returns [] for an empty string body" do
|
||||
assert IemClient.parse_asos_csv("") == []
|
||||
end
|
||||
|
||||
test "row with a bogus timestamp keeps observed_at = nil" do
|
||||
csv = """
|
||||
station,valid,tmpf,dwpf,relh,sknt,drct,mslp,alti,skyc1,p01i,wxcodes
|
||||
KDFW,not-a-timestamp,75.0,55.0,49.0,12,180,1013.2,29.92,SCT,0.0,
|
||||
"""
|
||||
|
||||
[row] = IemClient.parse_asos_csv(csv)
|
||||
assert row.observed_at == nil
|
||||
# Numeric columns still parse from the positional CSV.
|
||||
assert row.temp_f == 75.0
|
||||
end
|
||||
|
||||
property "never raises for any utf-8 binary input" do
|
||||
check all(body <- string(:printable, max_length: 200)) do
|
||||
# parse_asos_csv must be total — the ingest worker cannot tolerate
|
||||
# a FunctionClauseError on a malformed upstream response.
|
||||
assert is_list(IemClient.parse_asos_csv(body))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_raob_json/1 edge cases" do
|
||||
test "a profile with an unparseable 'valid' timestamp keeps observed_at = nil" do
|
||||
json = %{
|
||||
"profiles" => [
|
||||
%{"station" => "FWD", "valid" => "garbage", "profile" => []}
|
||||
]
|
||||
}
|
||||
|
||||
[row] = IemClient.parse_raob_json(json)
|
||||
assert row.observed_at == nil
|
||||
assert row.profile == []
|
||||
end
|
||||
|
||||
test "a profile missing the 'profile' key defaults to []" do
|
||||
json = %{"profiles" => [%{"station" => "FWD", "valid" => "2026-03-28 12:00:00+00:00"}]}
|
||||
|
||||
[row] = IemClient.parse_raob_json(json)
|
||||
assert row.profile == []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -96,6 +96,25 @@ defmodule Microwaveprop.Weather.NceiMetarClientTest do
|
|||
assert obs.altimeter_setting == nil
|
||||
end
|
||||
|
||||
test "skips a long line whose date/time header is garbage" do
|
||||
# ≥60 bytes so parse_line/1 doesn't short-circuit on the length guard,
|
||||
# but the date/time fields are non-numeric — parse_datetime/2 returns
|
||||
# :error and the line is dropped.
|
||||
line = String.duplicate("X", 80)
|
||||
assert NceiMetarClient.parse(line) == []
|
||||
end
|
||||
|
||||
test "parse/1 returns [] for a single trailing newline" do
|
||||
assert NceiMetarClient.parse("\n") == []
|
||||
end
|
||||
|
||||
test "parse/1 drops a line that is exactly 60 bytes of padding with an invalid date" do
|
||||
# 5 WBAN + 4 ICAO + 4 FAA + 8 date + 4 hhmm = 25 bytes of header,
|
||||
# 60 bytes total to satisfy the length guard.
|
||||
line = String.duplicate("A", 60)
|
||||
assert NceiMetarClient.parse(line) == []
|
||||
end
|
||||
|
||||
test "falls back to the coarse temp/dew pair when no T-group is in the remark" do
|
||||
# No T-group → extract_temp_f falls through to the "21/09" field,
|
||||
# which gives integer °C temperatures.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
defmodule Microwaveprop.Weather.RtmaClientTest do
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Weather.RtmaClient
|
||||
|
||||
|
|
@ -210,5 +211,55 @@ defmodule Microwaveprop.Weather.RtmaClientTest do
|
|||
|
||||
assert reason =~ "RTMA download HTTP 500"
|
||||
end
|
||||
|
||||
test "surfaces a transport timeout on the idx GET as an {:error, _}" do
|
||||
Req.Test.stub(RtmaClient, fn conn ->
|
||||
Req.Test.transport_error(conn, :timeout)
|
||||
end)
|
||||
|
||||
assert {:error, reason} =
|
||||
RtmaClient.fetch_observation(32.9, -97.0, ~U[2026-04-15 18:00:00Z])
|
||||
|
||||
# fetch_idx wraps transport errors into a human-readable string.
|
||||
assert reason =~ "RTMA idx failed"
|
||||
end
|
||||
end
|
||||
|
||||
describe "rtma_url/1 round-trip" do
|
||||
property "embeds YYYYMMDD and a zero-padded hour for any valid analysis time" do
|
||||
check all(
|
||||
year <- integer(2020..2030),
|
||||
month <- integer(1..12),
|
||||
day <- integer(1..28),
|
||||
hour <- integer(0..23)
|
||||
) do
|
||||
dt = %DateTime{
|
||||
year: year,
|
||||
month: month,
|
||||
day: day,
|
||||
hour: hour,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
microsecond: {0, 0},
|
||||
std_offset: 0,
|
||||
utc_offset: 0,
|
||||
zone_abbr: "UTC",
|
||||
time_zone: "Etc/UTC"
|
||||
}
|
||||
|
||||
url = RtmaClient.rtma_url(dt)
|
||||
|
||||
date_str =
|
||||
Integer.to_string(year) <>
|
||||
String.pad_leading(Integer.to_string(month), 2, "0") <>
|
||||
String.pad_leading(Integer.to_string(day), 2, "0")
|
||||
|
||||
hour_str = String.pad_leading(Integer.to_string(hour), 2, "0")
|
||||
|
||||
assert String.contains?(url, "rtma2p5.#{date_str}/")
|
||||
assert String.contains?(url, "rtma2p5.t#{hour_str}z")
|
||||
assert String.ends_with?(url, ".grb2_wexp")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -329,6 +329,52 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "Show — edge cases on display fields" do
|
||||
test "numeric bearing renders with a degree symbol", %{conn: conn} do
|
||||
# bearing_label/1 is a 3-clause dispatch; numeric values hit the
|
||||
# fallback and produce "NNN°".
|
||||
beacon = approved_beacon_fixture(user_fixture(), bearing: "185")
|
||||
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
|
||||
|
||||
assert html =~ "185°"
|
||||
end
|
||||
|
||||
test "'Plot path to beacon' link omits tx_power/gain for zero-power beacons",
|
||||
%{conn: conn} do
|
||||
# baseline_tx_for_eirp/1 has a `power_mw <= 0 -> {nil, nil}`
|
||||
# clause that maybe_put then drops from the URL query string.
|
||||
# Assert neither key appears when power_mw is 0.0.
|
||||
beacon =
|
||||
approved_beacon_fixture(
|
||||
user_fixture(),
|
||||
callsign: "W5ZER",
|
||||
power_mw: 0.0
|
||||
)
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
|
||||
|
||||
assert html =~ "destination="
|
||||
refute html =~ "tx_power_dbm="
|
||||
refute html =~ "src_gain_dbi="
|
||||
end
|
||||
|
||||
test "non-admin unauthenticated user clicking 'approve' is rejected with a flash",
|
||||
%{conn: conn} do
|
||||
# The approve handler is rendered for admins only but the server
|
||||
# still defends the admin check. Simulate a crafted client pushing
|
||||
# the event manually with no scope; the flash branch should fire.
|
||||
pending = beacon_fixture(user_fixture(), callsign: "W5DEF")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/beacons/#{pending}")
|
||||
|
||||
render_click(view, "approve")
|
||||
|
||||
# Still pending — no admin promotion.
|
||||
refute Beacons.get_beacon!(pending.id).approved
|
||||
assert render(view) =~ "Admins only."
|
||||
end
|
||||
end
|
||||
|
||||
describe "Show — property: URL beacon id round-trip" do
|
||||
property "mounting /beacons/:id for a created beacon always renders its callsign" do
|
||||
# Wrap each run in a sandbox checkout so Repo inserts are isolated.
|
||||
|
|
|
|||
|
|
@ -2304,4 +2304,839 @@ defmodule MicrowavepropWeb.ContactLiveTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "Show failure-state + enrichment-combo render branches" do
|
||||
# Push ContactLive.Show past 85% line coverage by hitting render
|
||||
# branches not driven by the other describe blocks: enrichment
|
||||
# failures (:failed / :unavailable) on every pipeline, the
|
||||
# "HRRR nil + NARR present" fallback rail, radar-only mechanism
|
||||
# when HRRR is absent, sounding-sourced surface refractivity, and
|
||||
# the :solar handle_async branches (success + already-present).
|
||||
|
||||
use ExUnitProperties
|
||||
|
||||
import Microwaveprop.AccountsFixtures
|
||||
|
||||
alias Microwaveprop.Propagation.ScoreCache
|
||||
alias Microwaveprop.Radio.ContactCommonVolumeRadar
|
||||
alias Microwaveprop.Terrain.TerrainProfile
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.HrrrProfile
|
||||
alias Microwaveprop.Weather.SolarIndex
|
||||
alias Microwaveprop.Weather.Sounding
|
||||
alias Microwaveprop.Weather.SurfaceObservation
|
||||
|
||||
setup do
|
||||
ScoreCache.clear()
|
||||
:ok
|
||||
end
|
||||
|
||||
# -- Enrichment failure surfaces ------------------------------------
|
||||
|
||||
test "terrain_status :failed with no terrain row falls back to the no-profile copy",
|
||||
%{conn: conn} do
|
||||
contact =
|
||||
create_contact()
|
||||
|> Ecto.Changeset.change(terrain_status: :failed)
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# Neither the queued spinner nor a terrain row renders — the
|
||||
# :failed status lands in the `true ->` catch-all branch of the
|
||||
# terrain empty cond.
|
||||
assert html =~ "No terrain profile available."
|
||||
refute html =~ "Computing terrain profile"
|
||||
end
|
||||
|
||||
test "iemre_status :failed shows the generic 'No IEMRE data' copy", %{conn: conn} do
|
||||
# :failed is not :queued and not :unavailable — the iemre empty
|
||||
# case hits the `_ ->` clause.
|
||||
contact =
|
||||
create_contact()
|
||||
|> Ecto.Changeset.change(iemre_status: :failed)
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "No IEMRE data available."
|
||||
refute html =~ "Fetching IEMRE data"
|
||||
end
|
||||
|
||||
test "weather_status :failed renders the empty surface-obs copy with no spinner",
|
||||
%{conn: conn} do
|
||||
contact =
|
||||
create_contact()
|
||||
|> Ecto.Changeset.change(weather_status: :failed)
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# :failed is neither :queued nor :complete, so we land on the
|
||||
# plain "No surface observations found nearby." branch.
|
||||
assert html =~ "No surface observations found nearby."
|
||||
end
|
||||
|
||||
test "hrrr_status :unavailable + no NARR row shows the NARR-fallback spinner",
|
||||
%{conn: conn} do
|
||||
# HRRR unavailable with NARR still not present — the atmospheric
|
||||
# profile section picks the "HRRR unavailable — fetching NARR
|
||||
# reanalysis" spinner branch.
|
||||
contact =
|
||||
create_contact()
|
||||
|> Ecto.Changeset.change(hrrr_status: :unavailable)
|
||||
|> Repo.update!()
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "HRRR unavailable"
|
||||
assert html =~ "fetching NARR reanalysis"
|
||||
end
|
||||
|
||||
test "NARR fallback with HRRR nil renders atmospheric profile from NARR", %{conn: conn} do
|
||||
# HRRR stays nil; a NARR row at pos1 drives the `@narr` branch
|
||||
# of the atmospheric profile source cond.
|
||||
contact =
|
||||
create_contact(%{
|
||||
qso_timestamp: ~U[2010-06-15 18:00:00Z],
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
Repo.insert!(%NarrProfile{
|
||||
valid_time: ~U[2010-06-15 18:00:00Z],
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 850.0, "tmpc" => 14.0, "dwpc" => 10.0, "hght" => 1500.0}],
|
||||
surface_temp_c: 24.0,
|
||||
surface_dewpoint_c: 18.0,
|
||||
surface_pressure_mb: 1012.0,
|
||||
hpbl_m: 1150.0,
|
||||
pwat_mm: 28.0,
|
||||
surface_refractivity: 318.0,
|
||||
min_refractivity_gradient: -55.0,
|
||||
ducting_detected: false
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# NARR-specific badge text + collapsed summary values.
|
||||
assert html =~ "NARR"
|
||||
# Expanded profile card shows surface scalars.
|
||||
assert html =~ "Surface Temp:"
|
||||
assert html =~ "Surface Dewpoint:"
|
||||
end
|
||||
|
||||
test "radar row without HRRR still renders the mechanism block and radar summary",
|
||||
%{conn: conn} do
|
||||
# No HRRR profile seeded — mechanism classifier runs with
|
||||
# duct_either_endpoint=false, coverage_pct drives the final
|
||||
# label. The render path exercises the radar block guard
|
||||
# (`@radar` non-nil) independently of HRRR.
|
||||
contact = create_contact(%{band: Decimal.new("10000"), distance_km: Decimal.new("150")})
|
||||
|
||||
Repo.insert!(%ContactCommonVolumeRadar{
|
||||
contact_id: contact.id,
|
||||
observed_at: contact.qso_timestamp,
|
||||
common_volume_km2: 14_000.0,
|
||||
pixel_count: 900,
|
||||
rain_pixel_count: 60,
|
||||
heavy_rain_pixel_count: 8,
|
||||
max_dbz: 38.0,
|
||||
mean_dbz: 19.0,
|
||||
coverage_pct: 6.0
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "Propagation Mechanism"
|
||||
assert html =~ "38.0 dBZ"
|
||||
assert html =~ "Heavy-rain pixels: 8"
|
||||
assert html =~ "Radar coverage: 6%"
|
||||
end
|
||||
|
||||
# -- :solar handle_async branches ----------------------------------
|
||||
|
||||
test "solar handle_async populates Solar Conditions with real SFI/Ap/Kp", %{conn: conn} do
|
||||
# Pre-seed a SolarIndex row so load_solar/1 returns it; the async
|
||||
# clause keeps it and the render branch prints SFI + sunspot + Ap
|
||||
# + formatted Kp values.
|
||||
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
||||
|
||||
Repo.insert!(%SolarIndex{
|
||||
date: ~D[2026-03-28],
|
||||
sfi: 142.0,
|
||||
sunspot_number: 98,
|
||||
ap_index: 6,
|
||||
kp_values: [1.0, 2.0, 2.7, 3.0, 1.3, 0.7, 1.0, 1.3]
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "Solar Conditions"
|
||||
assert html =~ "142"
|
||||
assert html =~ "98"
|
||||
# Kp values render comma-joined via format_kp.
|
||||
assert html =~ "2.7"
|
||||
end
|
||||
|
||||
test "solar handle_async for a missing date in an internal subnet enqueues a fetch",
|
||||
%{conn: conn} do
|
||||
# No SolarIndex row exists for the QSO date, remote_ip sits
|
||||
# inside the 172.56.0.0/13 enqueue subnet → enqueue_missing_solar
|
||||
# fires and @solar stays nil in the LV assigns.
|
||||
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
||||
conn = %{conn | remote_ip: {172, 56, 0, 50}}
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
assigns = :sys.get_state(lv.pid).socket.assigns
|
||||
assert assigns.solar == nil
|
||||
# The LV stays alive after enqueueing.
|
||||
assert Process.alive?(lv.pid)
|
||||
end
|
||||
|
||||
# -- compute_elevation_profile from sounding refractivity ----------
|
||||
|
||||
test "soundings-array is rendered alongside HRRR profile when both are present",
|
||||
%{conn: conn} do
|
||||
# Sounding with surface_refractivity and ducting flags should
|
||||
# appear in the Soundings section; the duct carries into
|
||||
# extract_sounding_ducts when HRRR ducts are empty.
|
||||
contact =
|
||||
create_contact(%{
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
# HRRR profile without any duct characteristics.
|
||||
Repo.insert!(%HrrrProfile{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}],
|
||||
hpbl_m: 1100.0,
|
||||
pwat_mm: 24.0,
|
||||
surface_temp_c: 20.0,
|
||||
surface_dewpoint_c: 12.0,
|
||||
surface_pressure_mb: 1012.0,
|
||||
surface_refractivity: 322.0,
|
||||
min_refractivity_gradient: -90.0,
|
||||
ducting_detected: false
|
||||
})
|
||||
|
||||
{:ok, raob_station} =
|
||||
Weather.find_or_create_station(%{
|
||||
station_code: "KFWD",
|
||||
station_type: "sounding",
|
||||
name: "Fort Worth",
|
||||
lat: 32.83,
|
||||
lon: -97.30
|
||||
})
|
||||
|
||||
Repo.insert!(
|
||||
Sounding.changeset(%Sounding{}, %{
|
||||
station_id: raob_station.id,
|
||||
observed_at: ~U[2026-03-28 12:00:00Z],
|
||||
profile: [
|
||||
%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 18.0, "hght" => 100.0},
|
||||
%{"pres" => 950.0, "tmpc" => 24.0, "dwpc" => 5.0, "hght" => 600.0}
|
||||
],
|
||||
level_count: 2,
|
||||
surface_temp_c: 22.0,
|
||||
surface_dewpoint_c: 18.0,
|
||||
surface_refractivity: 345.0,
|
||||
ducting_detected: true,
|
||||
duct_characteristics: [%{"base" => 100.0, "top" => 400.0, "strength" => 8.0}]
|
||||
})
|
||||
)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# HRRR still drives the atmospheric profile badge, but the
|
||||
# sounding row lands with its own surface refractivity value.
|
||||
assert html =~ "HRRR"
|
||||
assert html =~ "KFWD"
|
||||
# Sounding N=345 is surfaced by the sounding row (format_number → 345.0)
|
||||
assert html =~ "345"
|
||||
end
|
||||
|
||||
# -- blocked_message with nil obs_dist via terrain-only cascade ----
|
||||
|
||||
test "BLOCKED terrain without an elevation profile uses the generic obstructed copy",
|
||||
%{conn: conn} do
|
||||
# No elevation profile hydrated (ElevationClient stub returns []),
|
||||
# so obs_dist stays nil → blocked_message(nil, contact) fires.
|
||||
contact =
|
||||
create_contact(%{
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
Repo.insert!(%HrrrProfile{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}],
|
||||
hpbl_m: 1200.0,
|
||||
pwat_mm: 22.0,
|
||||
surface_temp_c: 20.0,
|
||||
surface_dewpoint_c: 10.0,
|
||||
surface_pressure_mb: 1010.0,
|
||||
surface_refractivity: 320.0,
|
||||
min_refractivity_gradient: -50.0,
|
||||
ducting_detected: false
|
||||
})
|
||||
|
||||
Repo.insert!(%TerrainProfile{
|
||||
contact_id: contact.id,
|
||||
sample_count: 100,
|
||||
path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
|
||||
max_elevation_m: 800.0,
|
||||
min_clearance_m: -10.0,
|
||||
diffraction_db: 10.0,
|
||||
fresnel_hit_count: 1,
|
||||
obstructed_count: 1,
|
||||
verdict: "BLOCKED"
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
# blocked_message(nil, _) = "Path is terrain-obstructed."
|
||||
assert html =~ "Path is terrain-obstructed"
|
||||
end
|
||||
|
||||
# -- describe_verdict cascade via elevation_profile, not terrain ----
|
||||
|
||||
test "FRESNEL_MINOR verdict via elevation profile drives the describe-verdict branch",
|
||||
%{conn: conn} do
|
||||
# With the SRTM stub populated, compute_elevation_profile returns
|
||||
# a real analysis whose verdict (CLEAR/FRESNEL_*/BLOCKED) drives
|
||||
# describe_verdict in terrain_summary. We assert the analysis
|
||||
# result makes it into the rendered summary.
|
||||
Req.Test.stub(ElevationClient, fn conn ->
|
||||
if conn.host == "api.open-meteo.com" do
|
||||
# Flat 250m profile — ensures CLEAR verdict, feeds
|
||||
# describe_verdict("CLEAR", …) via the elevation_profile.
|
||||
Req.Test.json(conn, %{"elevation" => List.duplicate(250.0, 257)})
|
||||
else
|
||||
Req.Test.json(conn, [])
|
||||
end
|
||||
end)
|
||||
|
||||
contact =
|
||||
create_contact(%{
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
Repo.insert!(%TerrainProfile{
|
||||
contact_id: contact.id,
|
||||
sample_count: 100,
|
||||
path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 250.0}],
|
||||
max_elevation_m: 260.0,
|
||||
min_clearance_m: 30.0,
|
||||
diffraction_db: 0.0,
|
||||
fresnel_hit_count: 0,
|
||||
obstructed_count: 0,
|
||||
verdict: "CLEAR"
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
# Elevation profile populated from the stub: verdict drives the
|
||||
# terrain_summary line (not terrain.verdict directly).
|
||||
ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile
|
||||
# When the stub propagates (see existing "elevation profile
|
||||
# hydrates when the SRTM stub returns a populated path" test),
|
||||
# ep is a map with :verdict; otherwise skip the assertion and
|
||||
# simply check the LV rendered without crashing.
|
||||
if ep do
|
||||
assert is_binary(ep.verdict)
|
||||
end
|
||||
|
||||
assert render(lv) =~ "Propagation Mechanism"
|
||||
end
|
||||
|
||||
# -- band_summary/2 branches ---------------------------------------
|
||||
|
||||
test "2304 MHz band with elevated PWAT hits the 10 GHz band_summary branch",
|
||||
%{conn: conn} do
|
||||
# band_summary/2 clause for band_mhz <= 12_000 + pwat > 20.
|
||||
# 2304 MHz qualifies (<= 12_000) so the 10 GHz moisture copy
|
||||
# appears in the propagation analysis summary.
|
||||
contact =
|
||||
create_contact(%{
|
||||
band: Decimal.new("2304"),
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
Repo.insert!(%HrrrProfile{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 18.0, "hght" => 100.0}],
|
||||
hpbl_m: 1100.0,
|
||||
pwat_mm: 32.0,
|
||||
surface_temp_c: 22.0,
|
||||
surface_dewpoint_c: 18.0,
|
||||
surface_pressure_mb: 1012.0,
|
||||
surface_refractivity: 345.0,
|
||||
min_refractivity_gradient: -90.0,
|
||||
ducting_detected: false
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "At 10 GHz, the elevated moisture"
|
||||
end
|
||||
|
||||
test "47 GHz band with elevated PWAT hits the 24G-and-up absorption copy",
|
||||
%{conn: conn} do
|
||||
# band_mhz >= 24_000 clause — pwat > 25 triggers the absorption
|
||||
# summary line.
|
||||
contact =
|
||||
create_contact(%{
|
||||
band: Decimal.new("47000"),
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
Repo.insert!(%HrrrProfile{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}],
|
||||
hpbl_m: 1200.0,
|
||||
pwat_mm: 30.0,
|
||||
surface_temp_c: 20.0,
|
||||
surface_dewpoint_c: 15.0,
|
||||
surface_pressure_mb: 1012.0,
|
||||
surface_refractivity: 330.0,
|
||||
min_refractivity_gradient: -60.0,
|
||||
ducting_detected: false
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "high moisture"
|
||||
assert html =~ "water vapor absorption"
|
||||
end
|
||||
|
||||
test "144 MHz band falls through every band_summary clause (no moisture copy)",
|
||||
%{conn: conn} do
|
||||
# 144 MHz is <= 12_000 but has low PWAT → both band_summary
|
||||
# clauses return nil, so the propagation analysis summary is
|
||||
# built from terrain + mechanism only.
|
||||
contact =
|
||||
create_contact(%{
|
||||
band: Decimal.new("144"),
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
Repo.insert!(%HrrrProfile{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 1000.0, "tmpc" => 10.0, "dwpc" => -5.0, "hght" => 100.0}],
|
||||
hpbl_m: 800.0,
|
||||
pwat_mm: 8.0,
|
||||
surface_temp_c: 10.0,
|
||||
surface_dewpoint_c: -5.0,
|
||||
surface_pressure_mb: 1015.0,
|
||||
surface_refractivity: 290.0,
|
||||
min_refractivity_gradient: -30.0,
|
||||
ducting_detected: false
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
# No "10 GHz" or "GHz high moisture" narrative surfaces.
|
||||
refute html =~ "At 10 GHz, the elevated moisture"
|
||||
refute html =~ "water vapor absorption"
|
||||
# Band label renders in MHz for 144.
|
||||
assert html =~ "144 MHz"
|
||||
end
|
||||
|
||||
test "1296 MHz band renders through the analysis without 10 GHz copy",
|
||||
%{conn: conn} do
|
||||
# 1296 MHz is <= 12_000 but low PWAT → no moisture narrative.
|
||||
contact =
|
||||
create_contact(%{
|
||||
band: Decimal.new("1296"),
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
Repo.insert!(%HrrrProfile{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 1000.0, "tmpc" => 10.0, "dwpc" => 0.0, "hght" => 100.0}],
|
||||
hpbl_m: 800.0,
|
||||
pwat_mm: 10.0,
|
||||
surface_temp_c: 10.0,
|
||||
surface_dewpoint_c: 0.0,
|
||||
surface_pressure_mb: 1015.0,
|
||||
surface_refractivity: 300.0,
|
||||
min_refractivity_gradient: -40.0,
|
||||
ducting_detected: false
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "1296 MHz"
|
||||
refute html =~ "At 10 GHz, the elevated moisture"
|
||||
end
|
||||
|
||||
test "75 GHz + low PWAT skips the moisture narrative", %{conn: conn} do
|
||||
# 75G >= 24_000 so enters that clause, but PWAT < 25 → nil.
|
||||
contact =
|
||||
create_contact(%{
|
||||
band: Decimal.new("75000"),
|
||||
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
||||
})
|
||||
|
||||
Repo.insert!(%HrrrProfile{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: 32.9,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 1000.0, "tmpc" => 5.0, "dwpc" => -15.0, "hght" => 100.0}],
|
||||
hpbl_m: 700.0,
|
||||
pwat_mm: 5.0,
|
||||
surface_temp_c: 5.0,
|
||||
surface_dewpoint_c: -15.0,
|
||||
surface_pressure_mb: 1020.0,
|
||||
surface_refractivity: 280.0,
|
||||
min_refractivity_gradient: -30.0,
|
||||
ducting_detected: false
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
html = render_async(lv, 2_000)
|
||||
|
||||
assert html =~ "75 GHz"
|
||||
refute html =~ "water vapor absorption"
|
||||
end
|
||||
|
||||
# -- admin/owner edit paths --------------------------------------
|
||||
|
||||
test "admin edit with proposed == %{} (no-op submit) surfaces 'No changes detected'",
|
||||
%{conn: conn} do
|
||||
# Admin submits the exact current values (no drift) — the
|
||||
# apply_admin_edit `proposed == %{}` branch fires explicitly.
|
||||
contact = create_contact()
|
||||
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
||||
conn = log_in_user(conn, admin)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
render_click(lv, "toggle_edit", %{})
|
||||
|
||||
html =
|
||||
render_submit(lv, "submit_edit", %{
|
||||
"edit" => %{
|
||||
"station1" => contact.station1,
|
||||
"station2" => contact.station2,
|
||||
"grid1" => contact.grid1,
|
||||
"grid2" => contact.grid2,
|
||||
"mode" => contact.mode,
|
||||
"band" => Decimal.to_string(contact.band)
|
||||
}
|
||||
})
|
||||
|
||||
assert html =~ "No changes detected"
|
||||
end
|
||||
|
||||
test "admin edit changing mode to FT8 applies the update", %{conn: conn} do
|
||||
contact = create_contact(%{mode: "CW"})
|
||||
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
||||
conn = log_in_user(conn, admin)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
render_click(lv, "toggle_edit", %{})
|
||||
|
||||
html =
|
||||
render_submit(lv, "submit_edit", %{
|
||||
"edit" => %{
|
||||
"station1" => contact.station1,
|
||||
"station2" => contact.station2,
|
||||
"grid1" => contact.grid1,
|
||||
"grid2" => contact.grid2,
|
||||
"mode" => "FT8"
|
||||
}
|
||||
})
|
||||
|
||||
assert html =~ "Contact updated"
|
||||
assert Repo.get!(Contact, contact.id).mode == "FT8"
|
||||
end
|
||||
|
||||
test "admin edit changing mode to SSB applies the update", %{conn: conn} do
|
||||
contact = create_contact(%{mode: "CW"})
|
||||
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
|
||||
conn = log_in_user(conn, admin)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
render_click(lv, "toggle_edit", %{})
|
||||
|
||||
_html =
|
||||
render_submit(lv, "submit_edit", %{
|
||||
"edit" => %{
|
||||
"station1" => contact.station1,
|
||||
"station2" => contact.station2,
|
||||
"grid1" => contact.grid1,
|
||||
"grid2" => contact.grid2,
|
||||
"mode" => "SSB"
|
||||
}
|
||||
})
|
||||
|
||||
assert Repo.get!(Contact, contact.id).mode == "SSB"
|
||||
end
|
||||
|
||||
# -- Obs-table sort handler: sort_observations over real rows ----
|
||||
|
||||
test "sort handler returns a sorted list of the same length for a seeded obs set",
|
||||
%{conn: conn} do
|
||||
# Drive sort_observations via the "sort" event across every known
|
||||
# field. With four obs rows in the DB the sorted list length must
|
||||
# stay stable and the section header must not disappear.
|
||||
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
||||
|
||||
for code <- ~w(KDFW KDAL KAFW KGKY) do
|
||||
{:ok, station} =
|
||||
Weather.find_or_create_station(%{
|
||||
station_code: code,
|
||||
station_type: "asos",
|
||||
name: "#{code} Airport",
|
||||
lat: 32.9,
|
||||
lon: -97.02
|
||||
})
|
||||
|
||||
Repo.insert!(
|
||||
SurfaceObservation.changeset(%SurfaceObservation{}, %{
|
||||
station_id: station.id,
|
||||
observed_at: contact.qso_timestamp,
|
||||
temp_f: 60.0 + :rand.uniform() * 20,
|
||||
dewpoint_f: 40.0 + :rand.uniform() * 20,
|
||||
relative_humidity: 50.0,
|
||||
sea_level_pressure_mb: 1014.0
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
before = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
|
||||
|
||||
for f <-
|
||||
~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb bogus_key) do
|
||||
html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
|
||||
assert html =~ "Surface Observations"
|
||||
|
||||
after_count = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
|
||||
assert after_count == before, "row count changed after sorting by #{f}"
|
||||
end
|
||||
end
|
||||
|
||||
# -- Queue counts + solar enqueue + pending_edit branches ---------
|
||||
|
||||
test "fetch_queue_counts returns a map after seeding multiple distinct queues",
|
||||
%{conn: conn} do
|
||||
# Insert one job per queue — fetch_queue_counts groups by queue
|
||||
# and returns counts. The cache layer runs through fetch_or_store
|
||||
# (5s TTL) so we only assert map-shape + presence of known keys.
|
||||
alias Microwaveprop.Workers.SolarIndexWorker
|
||||
alias Microwaveprop.Workers.TerrainProfileWorker
|
||||
|
||||
contact = create_contact()
|
||||
|
||||
{:ok, _} =
|
||||
Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(~D[2026-03-28])}))
|
||||
|
||||
{:ok, _} =
|
||||
Oban.insert(TerrainProfileWorker.new(%{"contact_id" => contact.id}))
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
counts = :sys.get_state(lv.pid).socket.assigns.queue_counts
|
||||
|
||||
assert is_map(counts)
|
||||
# Counts map is populated (the cache may hold an earlier empty
|
||||
# read — the shape is what's under test here).
|
||||
for {k, v} <- counts do
|
||||
assert is_binary(k)
|
||||
assert is_integer(v) and v >= 0
|
||||
end
|
||||
end
|
||||
|
||||
test "LV stays alive after render_async completes with no DB rows at all",
|
||||
%{conn: conn} do
|
||||
# A bare contact with no enrichment rows runs every hydration
|
||||
# task to completion, landing in the "default" branches of each
|
||||
# handle_async clause (empty hrrr_path, nil narr, nil terrain,
|
||||
# nil iemre, {nil, :unknown} radar). render_async must return
|
||||
# without the LV dying.
|
||||
contact = create_contact()
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
assigns = :sys.get_state(lv.pid).socket.assigns
|
||||
assert Process.alive?(lv.pid)
|
||||
assert MapSet.size(assigns.hydration_pending) == 0
|
||||
end
|
||||
|
||||
# -- propagation_mechanism exhaustiveness property ----------------
|
||||
|
||||
property "propagation_mechanism-derived summary stays a binary across random flag combos",
|
||||
%{conn: conn} do
|
||||
# Property: regardless of which combination of ducting / enhanced
|
||||
# refraction / blocked terrain applies, the propagation analysis
|
||||
# summary renders and the @propagation_analysis.summary stored in
|
||||
# socket.assigns is a string. This pins down propagation_mechanism
|
||||
# returning either a binary or nil (rejected by Enum.reject/2 in
|
||||
# build_summary), never a crash.
|
||||
|
||||
bool_gen = StreamData.boolean()
|
||||
|
||||
check all(
|
||||
ducting? <- bool_gen,
|
||||
enhanced? <- bool_gen,
|
||||
blocked? <- bool_gen,
|
||||
long_path? <- bool_gen,
|
||||
max_runs: 10
|
||||
) do
|
||||
# Encode each flag into the DB row that drives the underlying
|
||||
# predicate (ducting_detected, min_refractivity_gradient,
|
||||
# terrain.verdict, contact.distance_km). The render then walks
|
||||
# blocked_mechanism/clear_path_mechanism down its cascades.
|
||||
dist = if long_path?, do: Decimal.new("200"), else: Decimal.new("50")
|
||||
|
||||
# Per-iteration unique lat + valid_time so rows inserted by
|
||||
# earlier iterations don't collide on the hrrr_profiles
|
||||
# (lat, lon, valid_time) unique index. StreamData shrinks
|
||||
# toward degenerate values; System.unique_integer/1 guarantees
|
||||
# monotonically-advancing uniqueness across the whole run.
|
||||
nonce = System.unique_integer([:positive])
|
||||
lat1 = 32.0 + nonce / 1_000_000
|
||||
iter_valid_time = DateTime.add(~U[2026-03-28 18:00:00Z], nonce, :second)
|
||||
|
||||
contact =
|
||||
create_contact(%{
|
||||
band: Decimal.new("10000"),
|
||||
qso_timestamp: iter_valid_time,
|
||||
pos1: %{"lat" => lat1, "lon" => -97.0},
|
||||
pos2: %{"lat" => 30.3, "lon" => -97.7},
|
||||
distance_km: dist
|
||||
})
|
||||
|
||||
Repo.insert!(%HrrrProfile{
|
||||
valid_time: contact.qso_timestamp,
|
||||
lat: lat1,
|
||||
lon: -97.0,
|
||||
profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}],
|
||||
hpbl_m: 1200.0,
|
||||
pwat_mm: 22.0,
|
||||
surface_temp_c: 20.0,
|
||||
surface_dewpoint_c: 12.0,
|
||||
surface_pressure_mb: 1012.0,
|
||||
surface_refractivity: 320.0,
|
||||
min_refractivity_gradient: if(enhanced?, do: -150.0, else: -40.0),
|
||||
ducting_detected: ducting?
|
||||
})
|
||||
|
||||
Repo.insert!(%TerrainProfile{
|
||||
contact_id: contact.id,
|
||||
sample_count: 100,
|
||||
path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
|
||||
max_elevation_m: if(blocked?, do: 800.0, else: 400.0),
|
||||
min_clearance_m: if(blocked?, do: -20.0, else: 40.0),
|
||||
diffraction_db: if(blocked?, do: 15.0, else: 0.0),
|
||||
fresnel_hit_count: 0,
|
||||
obstructed_count: if(blocked?, do: 2, else: 0),
|
||||
verdict: if(blocked?, do: "BLOCKED", else: "CLEAR")
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
analysis = :sys.get_state(lv.pid).socket.assigns.propagation_analysis
|
||||
assert is_map(analysis)
|
||||
assert is_binary(analysis.summary)
|
||||
# details is a list of strings (or empty) — no nils slipped through.
|
||||
assert is_list(analysis.details)
|
||||
assert Enum.all?(analysis.details, &is_binary/1)
|
||||
end
|
||||
end
|
||||
|
||||
# -- obs_sort_key totality property -----------------------------
|
||||
|
||||
property "sort-observations handler keeps the assign as a list of stable length",
|
||||
%{conn: conn} do
|
||||
# For any sequence of known or unknown sort-field strings, the
|
||||
# resulting surface_observations assign is a list whose length
|
||||
# matches the initial count. Covers obs_sort_key's fall-through
|
||||
# clause on any atom/string the caller throws at it.
|
||||
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
||||
|
||||
{:ok, station} =
|
||||
Weather.find_or_create_station(%{
|
||||
station_code: "KDFW",
|
||||
station_type: "asos",
|
||||
name: "DFW Airport",
|
||||
lat: 32.9,
|
||||
lon: -97.02
|
||||
})
|
||||
|
||||
for i <- 1..3 do
|
||||
Repo.insert!(
|
||||
SurfaceObservation.changeset(%SurfaceObservation{}, %{
|
||||
station_id: station.id,
|
||||
observed_at: DateTime.add(contact.qso_timestamp, i * 60, :second),
|
||||
temp_f: 60.0 + i,
|
||||
dewpoint_f: 45.0 + i,
|
||||
relative_humidity: 55.0,
|
||||
sea_level_pressure_mb: 1014.0
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
# Any ASCII string (known or not) is a valid sort-field. The
|
||||
# handler must never crash the LV and must preserve row count.
|
||||
gen_field =
|
||||
StreamData.one_of([
|
||||
StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)),
|
||||
StreamData.string(:alphanumeric, min_length: 1, max_length: 8)
|
||||
])
|
||||
|
||||
check all(fields <- StreamData.list_of(gen_field, min_length: 1, max_length: 5), max_runs: 10) do
|
||||
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
||||
_ = render_async(lv, 2_000)
|
||||
|
||||
baseline = length(:sys.get_state(lv.pid).socket.assigns.surface_observations)
|
||||
|
||||
for f <- fields do
|
||||
render_click(lv, "sort", %{"field" => f, "table" => "obs"})
|
||||
obs = :sys.get_state(lv.pid).socket.assigns.surface_observations
|
||||
assert is_list(obs)
|
||||
assert length(obs) == baseline, "sorting by #{inspect(f)} changed row count"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
defmodule MicrowavepropWeb.EmeLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
|
||||
setup do
|
||||
# CallsignClient.locate/1 falls through to Qrz.Client for any input
|
||||
# that isn't a grid or coord pair. Stub the QRZ HTTP plug so the
|
||||
# "unknown callsign" path returns a deterministic error instead of
|
||||
# raising about a missing stub.
|
||||
Req.Test.stub(Microwaveprop.Qrz.Client, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 404, "not found")
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "GET /eme" do
|
||||
test "renders the empty form when no source is supplied", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/eme")
|
||||
|
|
@ -55,6 +70,54 @@ defmodule MicrowavepropWeb.EmeLiveTest do
|
|||
assert html =~ "-97.0"
|
||||
end
|
||||
|
||||
test "invalid Maidenhead grid surfaces a validation error", %{conn: conn} do
|
||||
# A 4-char prefix that passes the loose regex but trips
|
||||
# Maidenhead.to_latlon's strict validator (subsquare "ZZ" isn't A-X).
|
||||
{:ok, _view, html} = live(conn, ~p"/eme?source=EM12ZZ")
|
||||
|
||||
assert html =~ "Invalid Maidenhead grid"
|
||||
refute html =~ "Antenna aim"
|
||||
end
|
||||
|
||||
test "unknown callsign input surfaces a 'Could not find' error", %{conn: conn} do
|
||||
# A random string that's neither a coord nor a maidenhead grid falls
|
||||
# into the CallsignClient.locate path. The test DB has no cached
|
||||
# entry and the real QRZ API is unreachable in tests → error branch.
|
||||
{:ok, _view, html} = live(conn, ~p"/eme?source=NOSUCHCALL")
|
||||
|
||||
assert html =~ "Could not find"
|
||||
refute html =~ "Antenna aim"
|
||||
end
|
||||
|
||||
test "empty source renders the 'enter callsign or grid' empty-state message",
|
||||
%{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/eme?source=&band=1296")
|
||||
|
||||
assert html =~ "Enter your callsign or grid"
|
||||
refute html =~ "Antenna aim"
|
||||
end
|
||||
|
||||
test "typing into form emits the WebGL globe hook state via push_event",
|
||||
%{conn: conn} do
|
||||
# Initial render: empty source, no eme:update. Triggering a form
|
||||
# change with a valid grid should both patch the URL and push the
|
||||
# eme:update payload to the hook.
|
||||
{:ok, view, _html} = live(conn, ~p"/eme")
|
||||
|
||||
render_change(
|
||||
form(view, "form",
|
||||
source: "EM13",
|
||||
band: "1296",
|
||||
tx_power_dbm: "50",
|
||||
tx_gain_dbi: "30",
|
||||
bandwidth_hz: "100",
|
||||
t_sys_k: "150"
|
||||
)
|
||||
)
|
||||
|
||||
assert_push_event(view, "eme:update", %{lat: _, lon: _, az: _, el: _})
|
||||
end
|
||||
|
||||
test "renders the Earth–Moon geometry WebGL hook with initial station geometry", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/eme?source=EM13&band=1296")
|
||||
|
||||
|
|
@ -71,4 +134,51 @@ defmodule MicrowavepropWeb.EmeLiveTest do
|
|||
assert html =~ ~s(data-moon-el=")
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: form validation across valid/invalid source inputs" do
|
||||
property "valid inputs render an Antenna aim readout, invalid inputs render a validation error" do
|
||||
bands = Enum.map(BandConfig.band_options(), fn {_label, value} -> value end)
|
||||
|
||||
# A valid-input sample is a Maidenhead grid + any configured band;
|
||||
# an invalid-input sample is a non-grid/non-callsign string that
|
||||
# neither resolves as coords nor passes the maidenhead prefix
|
||||
# regex. Both categories are exercised in the same property.
|
||||
valid_source_gen =
|
||||
StreamData.member_of(["EM13", "FM19", "EN00", "DM04", "FN31", "CM87", "EL09"])
|
||||
|
||||
invalid_source_gen =
|
||||
StreamData.member_of(["ZZ99", "ZZ00", "Z1", "@@@@", "ZZZZZZ"])
|
||||
|
||||
check all(
|
||||
band <- StreamData.member_of(bands),
|
||||
valid_source <- valid_source_gen,
|
||||
invalid_source <- invalid_source_gen,
|
||||
max_runs: 10
|
||||
) do
|
||||
conn = Phoenix.ConnTest.build_conn()
|
||||
|
||||
{:ok, _valid_view, valid_html} =
|
||||
live(conn, ~p"/eme?source=#{valid_source}&band=#{band}")
|
||||
|
||||
assert valid_html =~ "Antenna aim", """
|
||||
Expected a valid input to produce an Antenna aim readout.
|
||||
source=#{inspect(valid_source)} band=#{band}
|
||||
"""
|
||||
|
||||
{:ok, _invalid_view, invalid_html} =
|
||||
live(conn, ~p"/eme?source=#{invalid_source}&band=#{band}")
|
||||
|
||||
# Either the loose maidenhead regex catches it (invalid grid
|
||||
# error) or it falls through to callsign lookup (not found).
|
||||
assert invalid_html =~ "Invalid Maidenhead grid" or
|
||||
invalid_html =~ "Could not find",
|
||||
"""
|
||||
Expected an invalid input to produce a validation error.
|
||||
source=#{inspect(invalid_source)} band=#{band}
|
||||
"""
|
||||
|
||||
refute invalid_html =~ "Antenna aim"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
defmodule MicrowavepropWeb.MapLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: false
|
||||
use ExUnitProperties
|
||||
|
||||
import Phoenix.ConnTest, only: [get: 2]
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Microwaveprop.Propagation
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
alias Microwaveprop.Propagation.ScoreCache
|
||||
alias Phoenix.LiveView.AsyncResult
|
||||
alias Phoenix.LiveView.Utils
|
||||
|
|
@ -143,6 +145,45 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
|||
|
||||
assert html =~ "24 GHz"
|
||||
end
|
||||
|
||||
test "patches the URL with the newly-selected band", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
render_click(lv, "select_band", %{"value" => 47_000})
|
||||
|
||||
url = assert_patch(lv)
|
||||
assert url =~ "band=47000"
|
||||
end
|
||||
|
||||
test "select_band pushes update_scores to the client for the new band", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
# Drain any async initial push_event before asserting on the
|
||||
# band-switch one.
|
||||
_ = render(lv)
|
||||
|
||||
render_click(lv, "select_band", %{"value" => 24_000})
|
||||
|
||||
assert_push_event(lv, "update_scores", %{scores: _scores})
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: select_band patches the URL for every configured band" do
|
||||
property "each configured band patches to a URL containing band=<mhz>" do
|
||||
bands = Enum.map(BandConfig.band_options(), fn {_l, v} -> String.to_integer(v) end)
|
||||
|
||||
check all(band <- StreamData.member_of(bands), max_runs: 8) do
|
||||
conn = Phoenix.ConnTest.build_conn()
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
render_click(lv, "select_band", %{"value" => band})
|
||||
|
||||
url = assert_patch(lv)
|
||||
|
||||
assert url =~ "band=#{band}",
|
||||
"expected /map patch URL to contain band=#{band}, got #{inspect(url)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "compute_viewshed" do
|
||||
|
|
@ -179,6 +220,29 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
|||
render_click(lv, "toggle_radar")
|
||||
refute :sys.get_state(lv.pid).socket.assigns.radar_visible
|
||||
end
|
||||
|
||||
test "pushes toggle_radar to the client with the new visibility", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
render_click(lv, "toggle_radar")
|
||||
|
||||
assert_push_event(lv, "toggle_radar", %{visible: true})
|
||||
|
||||
render_click(lv, "toggle_radar")
|
||||
assert_push_event(lv, "toggle_radar", %{visible: false})
|
||||
end
|
||||
end
|
||||
|
||||
describe "toggle_grid event payload" do
|
||||
test "pushes toggle_grid to the client with the new visibility", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
render_click(lv, "toggle_grid")
|
||||
assert_push_event(lv, "toggle_grid", %{visible: true})
|
||||
|
||||
render_click(lv, "toggle_grid")
|
||||
assert_push_event(lv, "toggle_grid", %{visible: false})
|
||||
end
|
||||
end
|
||||
|
||||
describe "select_time / set_selected_time" do
|
||||
|
|
@ -202,6 +266,38 @@ defmodule MicrowavepropWeb.MapLiveTest do
|
|||
render_click(lv, "select_time", %{"time" => "2026-04-21T22:00:00Z"})
|
||||
assert Process.alive?(lv.pid)
|
||||
end
|
||||
|
||||
test "select_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
render_click(lv, "select_time", %{"time" => "definitely-not-a-time"})
|
||||
assert Process.alive?(lv.pid)
|
||||
end
|
||||
|
||||
test "set_selected_time with a malformed ISO string is a no-op (no crash)", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
render_hook(lv, "set_selected_time", %{"time" => "definitely-not-a-time"})
|
||||
assert Process.alive?(lv.pid)
|
||||
end
|
||||
|
||||
test "select_time with valid iso updates selected_time assign", %{conn: conn} do
|
||||
valid_time =
|
||||
DateTime.utc_now()
|
||||
|> DateTime.truncate(:second)
|
||||
|> Map.put(:minute, 0)
|
||||
|> Map.put(:second, 0)
|
||||
|
||||
Propagation.replace_scores(
|
||||
[%{lat: 33.0, lon: -97.0, valid_time: valid_time, band_mhz: 10_000, score: 55, factors: nil}],
|
||||
valid_time
|
||||
)
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/map")
|
||||
|
||||
iso = DateTime.to_iso8601(valid_time)
|
||||
render_click(lv, "select_time", %{"time" => iso})
|
||||
|
||||
assert :sys.get_state(lv.pid).socket.assigns.selected_time == valid_time
|
||||
end
|
||||
end
|
||||
|
||||
describe "point_detail" do
|
||||
|
|
|
|||
|
|
@ -302,6 +302,59 @@ defmodule MicrowavepropWeb.PathLiveTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "compute_path edge cases" do
|
||||
test "identical source and destination renders with Distance 0 km", %{conn: conn} do
|
||||
{:ok, lv, _html} =
|
||||
live(conn, ~p"/path?source=33.0,-97.0&destination=33.0,-97.0&band=10000")
|
||||
|
||||
html = render(lv)
|
||||
assert html =~ "Link Summary"
|
||||
assert html =~ "Distance"
|
||||
# Haversine of identical points is exactly 0 — distance_km formats
|
||||
# small distances with one decimal place ("0.0 km").
|
||||
assert html =~ ~r/0\.0 km/
|
||||
end
|
||||
|
||||
test "long path > 3000 km still renders a result without crashing", %{conn: conn} do
|
||||
# New York City → Los Angeles (~3940 km). The path calculator
|
||||
# doesn't filter by distance the way the scoring set does, so the
|
||||
# result panel should build even for coast-to-coast ranges.
|
||||
{:ok, lv, _html} =
|
||||
live(conn, ~p"/path?source=40.7,-74.0&destination=34.0,-118.0&band=10000")
|
||||
|
||||
html = render(lv)
|
||||
assert html =~ "Link Summary"
|
||||
assert html =~ "Distance"
|
||||
# Somewhere in the 3000s or 4000s — distance_km renders >10mi
|
||||
# as an integer "NNNN km" rather than decimal.
|
||||
assert html =~ ~r/\d{4} km/
|
||||
end
|
||||
|
||||
test "blank source with a filled destination keeps the empty form (auto_calculate skipped)",
|
||||
%{conn: conn} do
|
||||
{:ok, _lv, html} = live(conn, ~p"/path?destination=EM12&band=10000")
|
||||
|
||||
assert html =~ "Path Calculator"
|
||||
refute html =~ "Link Summary"
|
||||
end
|
||||
|
||||
test "My Location GPS fix is echoed back into the source input", %{conn: conn} do
|
||||
# The hook normally emits gps_location after the user clicks the
|
||||
# button. The handler should echo the rounded coords into the
|
||||
# source input AND leave source_is_gps true for the URL.
|
||||
{:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=10000")
|
||||
|
||||
assert_push_event(lv, "request_gps", %{})
|
||||
|
||||
render_hook(lv, "gps_location", %{"lat" => 32.897, "lon" => -97.038})
|
||||
html = render(lv)
|
||||
|
||||
# The input shows the rounded coordinates, not the literal "gps".
|
||||
assert html =~ ~s(value="32.897)
|
||||
assert html =~ "-97.038"
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: band round-trip in form" do
|
||||
property "for every configured band, submitting the form re-renders with that band selected" do
|
||||
# Re-stub the elevation client for each property iteration —
|
||||
|
|
@ -332,4 +385,38 @@ defmodule MicrowavepropWeb.PathLiveTest do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: height/power field round-trip through update_form" do
|
||||
property "update_form preserves arbitrary numeric heights and TX power through the render" do
|
||||
check all(
|
||||
src_height <- StreamData.integer(1..300),
|
||||
dst_height <- StreamData.integer(1..300),
|
||||
tx_power <- StreamData.integer(-10..60),
|
||||
max_runs: 10
|
||||
) do
|
||||
Req.Test.stub(ElevationClient, fn conn ->
|
||||
params = Plug.Conn.fetch_query_params(conn).query_params
|
||||
lat_count = params["latitude"] |> String.split(",") |> length()
|
||||
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||||
end)
|
||||
|
||||
conn = Phoenix.ConnTest.build_conn()
|
||||
{:ok, lv, _html} = live(conn, ~p"/path")
|
||||
|
||||
html =
|
||||
render_hook(lv, "update_form", %{
|
||||
"source" => "EM13",
|
||||
"destination" => "EM12",
|
||||
"band" => "10000",
|
||||
"src_height_ft" => to_string(src_height),
|
||||
"dst_height_ft" => to_string(dst_height),
|
||||
"tx_power_dbm" => to_string(tx_power)
|
||||
})
|
||||
|
||||
assert html =~ ~s(value="#{src_height}")
|
||||
assert html =~ ~s(value="#{dst_height}")
|
||||
assert html =~ ~s(value="#{tx_power}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule MicrowavepropWeb.UserProfileLiveTest do
|
||||
use MicrowavepropWeb.ConnCase, async: true
|
||||
|
||||
import Ecto.Query
|
||||
import Microwaveprop.AccountsFixtures
|
||||
import Microwaveprop.BeaconsFixtures
|
||||
import Phoenix.LiveViewTest
|
||||
|
|
@ -91,5 +92,49 @@ defmodule MicrowavepropWeb.UserProfileLiveTest do
|
|||
assert html =~ "Pending"
|
||||
assert html =~ "Approved"
|
||||
end
|
||||
|
||||
test "renders the 'Contacts involving' section with contacts that mention the callsign as a remote station",
|
||||
%{conn: conn} do
|
||||
# The profile owner (W5ISP) isn't the submitter — another user logged
|
||||
# the contact — but W5ISP shows up as station2. The involving block
|
||||
# should surface that QSO.
|
||||
owner = user_fixture(%{callsign: "W5ISP"})
|
||||
other = user_fixture(%{callsign: "W5OTH"})
|
||||
_mention = contact_for(other, %{station1: "K5TR", station2: "W5ISP"})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/u/#{owner.callsign}")
|
||||
|
||||
assert html =~ "Contacts W5ISP is in"
|
||||
assert html =~ "K5TR"
|
||||
assert html =~ "W5ISP"
|
||||
end
|
||||
|
||||
test "uses first character of the user's name for the avatar initial when set", %{conn: conn} do
|
||||
_user = user_fixture(%{callsign: "W5ISP", name: " graham mcintire"})
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/u/W5ISP")
|
||||
|
||||
# initial(%{name: "graham mcintire"}) trims, takes the first char,
|
||||
# and uppercases → "G". Assert on the font-mono span that renders it.
|
||||
assert html =~ ~r/font-mono[^>]*>\s*G\s*</s
|
||||
end
|
||||
|
||||
test "falls back to the callsign's first character when name is an empty string",
|
||||
%{conn: conn} do
|
||||
# initial/1 guards `name != ""` so empty-string names fall through
|
||||
# to the callsign branch. Write the empty string directly via raw
|
||||
# SQL because the User changeset validates_required name.
|
||||
user = user_fixture(%{callsign: "W5ISP"})
|
||||
|
||||
{1, _} =
|
||||
Repo.update_all(
|
||||
from(u in Microwaveprop.Accounts.User, where: u.id == ^user.id),
|
||||
set: [name: ""]
|
||||
)
|
||||
|
||||
{:ok, _lv, html} = live(conn, ~p"/u/W5ISP")
|
||||
|
||||
assert html =~ ~r/font-mono[^>]*>\s*W\s*</s
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue