prop/test/microwaveprop_web/live/contact_live/show_hydration_test.exs
Graham McIntire cb8445f329
fix: resolve 158/183 credo --strict warnings
- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg)
- Move 12+ nested alias/import/require to module top level
- Add phx-change/id attributes to 11 raw HTML <form> tags
- Remove 4 unused LiveView assigns (:bounds, :data_provider)
- Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts)
- Break 2 long lines (path_compute.ex:382)
- Strengthen weak test assertions (is_binary→byte_size, is_list→!=[])
- Replace Module.concat with Module.safe_concat (2 occurrences)
- Replace length/1 > 0 with list != [] (9 occurrences)
- Remove no-op assert true, fix no-assertion tests

Remaining: 24 socket.assigns introspection warnings (deliberate test
pattern for observable behavior testing), 1 formatter-resistant long
line, 3 app-code usage warnings.
2026-06-12 15:47:15 -05:00

665 lines
20 KiB
Elixir

defmodule MicrowavepropWeb.ContactLive.ShowHydrationTest do
use MicrowavepropWeb.ConnCase, async: true
import Microwaveprop.AccountsFixtures
import Phoenix.LiveViewTest
alias Microwaveprop.Propagation.ScoreCache
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.IemreObservation
alias Microwaveprop.Weather.NarrProfile
alias Microwaveprop.Weather.Sounding
alias Microwaveprop.Weather.SurfaceObservation
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 fully-hydrated render paths" do
defp seed_fully_hydrated do
contact =
create_contact(%{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
})
_ =
Repo.insert!(%HrrrProfile{
valid_time: contact.qso_timestamp,
lat: 32.9,
lon: -97.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
%{"pres" => 925.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 800.0},
%{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
],
hpbl_m: 1200.0,
pwat_mm: 25.0,
surface_temp_c: 20.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1012.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -120.0,
ducting_detected: false
})
_ =
Repo.insert!(%TerrainProfile{
contact_id: contact.id,
sample_count: 100,
path_points: [%{"km" => 0.0, "elev_m" => 200.0}, %{"km" => 295.0, "elev_m" => 250.0}],
max_elevation_m: 450.0,
min_clearance_m: 20.0,
diffraction_db: 6.5,
fresnel_hit_count: 3,
obstructed_count: 1,
verdict: "CLEAR"
})
_ =
Repo.insert!(%ContactCommonVolumeRadar{
contact_id: contact.id,
observed_at: contact.qso_timestamp,
common_volume_km2: 12_000.0,
pixel_count: 500,
rain_pixel_count: 5,
heavy_rain_pixel_count: 0,
max_dbz: 22.0,
mean_dbz: 12.0,
coverage_pct: 1.0
})
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.9,
lon: -97.02
})
Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id,
observed_at: contact.qso_timestamp,
temp_f: 72.0,
dewpoint_f: 55.0,
relative_humidity: 55.0,
sea_level_pressure_mb: 1015.0
})
)
{:ok, raob_station} =
Weather.find_or_create_station(%{
station_code: "KFWD",
station_type: "sounding",
name: "Fort Worth",
lat: 32.83,
lon: -97.30
})
Repo.insert!(
Sounding.changeset(%Sounding{}, %{
station_id: raob_station.id,
observed_at: ~U[2026-03-28 12:00:00Z],
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
],
level_count: 2,
surface_temp_c: 20.0,
surface_dewpoint_c: 15.0
})
)
_ =
Repo.insert!(%IemreObservation{
date: ~D[2026-03-28],
lat: 31.625,
lon: -97.375,
hourly: [
%{"utc_hour" => 18, "air_temp_f" => 70.0, "dew_point_f" => 55.0, "skyc_pct" => 30.0}
]
})
contact
end
setup do
ScoreCache.clear()
:ok
end
test "fully populated contact renders every enrichment section", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ contact.station1
assert html =~ "Atmospheric Profile"
assert html =~ "Surface Observations"
assert html =~ "Soundings"
assert html =~ "1200"
assert html =~ "Line of sight is clear"
end
test "handle_info :terrain_ready refreshes the terrain section", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
send(lv.pid, {:terrain_ready, contact.id})
html = render(lv)
assert html =~ contact.station1
end
test "handle_info ignores terrain_ready for unrelated contact", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
send(lv.pid, {:terrain_ready, Ecto.UUID.generate()})
assert Process.alive?(lv.pid)
end
test "handle_info :sounding_fetched rehydrates the soundings table", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
send(lv.pid, {:sounding_fetched, %{contact_id: contact.id}})
html = render(lv)
assert html =~ "Soundings"
end
test "handle_info :sounding_fetched for a different contact is a no-op", %{conn: conn} do
contact = seed_fully_hydrated()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}})
assert Process.alive?(lv.pid)
end
test "flagged_invalid contact shows the Flagged invalid badge", %{conn: conn} do
contact = create_contact()
{:ok, contact} =
contact
|> Ecto.Changeset.change(flagged_invalid: true)
|> Repo.update()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Flagged invalid"
assert html =~ "line-through"
end
test "owner sees an Edit button (not Suggest Edit) for their own contact", %{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}")
assert html =~ "Edit"
refute html =~ "Suggest Edit"
end
end
describe "Show render-branch coverage" do
use ExUnitProperties
defp seed_contact_with_overrides(opts \\ []) do
contact_overrides = Keyword.get(opts, :contact, %{})
terrain_overrides = Keyword.get(opts, :terrain, %{})
hrrr_overrides = Keyword.get(opts, :hrrr, %{})
contact =
create_contact(
Map.merge(
%{
pos1: %{"lat" => 32.9, "lon" => -97.0},
pos2: %{"lat" => 30.3, "lon" => -97.7}
},
contact_overrides
)
)
hrrr_row =
Map.merge(
%{
valid_time: contact.qso_timestamp,
lat: 32.9,
lon: -97.0,
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
],
hpbl_m: 1200.0,
pwat_mm: 25.0,
surface_temp_c: 20.0,
surface_dewpoint_c: 12.0,
surface_pressure_mb: 1012.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -120.0,
ducting_detected: false
},
hrrr_overrides
)
Repo.insert!(struct!(HrrrProfile, hrrr_row))
terrain_row =
Map.merge(
%{
contact_id: contact.id,
sample_count: 100,
path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
max_elevation_m: 450.0,
min_clearance_m: 20.0,
diffraction_db: 6.5,
fresnel_hit_count: 0,
obstructed_count: 0,
verdict: "CLEAR"
},
terrain_overrides
)
Repo.insert!(struct!(TerrainProfile, terrain_row))
contact
end
setup do
ScoreCache.clear()
:ok
end
test "IEMRE section renders nearest-hour temperature and dew point", %{conn: conn} do
contact = create_contact()
Repo.insert!(%IemreObservation{
date: DateTime.to_date(contact.qso_timestamp),
lat: 32.875,
lon: -97.0,
hourly: [
%{
"utc_hour" => 18,
"air_temp_f" => 73.4,
"dew_point_f" => 54.3,
"skyc_%" => 42.0,
"uwnd_mps" => 3.0,
"vwnd_mps" => 4.0,
"hourly_precip_in" => 0.0
}
]
})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "IEMRE Gridded Reanalysis"
assert html =~ "73.4"
assert html =~ "54.3"
end
test "IEMRE section shows 'unavailable' status when marked as such", %{conn: conn} do
contact =
create_contact()
|> Ecto.Changeset.change(iemre_status: :unavailable)
|> Repo.update!()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "IEMRE data unavailable for this contact."
end
test "BLOCKED terrain verdict shows the obstruction badge class", %{conn: conn} do
contact =
seed_contact_with_overrides(
terrain: %{
verdict: "BLOCKED",
obstructed_count: 4,
fresnel_hit_count: 2,
diffraction_db: 18.2
}
)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "BLOCKED"
assert html =~ "badge-error"
assert html =~ "obstructed"
end
test "FRESNEL_MINOR terrain verdict renders with warning styling", %{conn: conn} do
contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_MINOR"})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "FRESNEL_MINOR"
assert html =~ "badge-warning"
end
test "FRESNEL_PARTIAL terrain verdict renders with warning styling", %{conn: conn} do
contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_PARTIAL"})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "FRESNEL_PARTIAL"
assert html =~ "badge-warning"
end
test "expanded terrain table renders all path points", %{conn: conn} do
contact =
seed_contact_with_overrides(
terrain: %{
verdict: "CLEAR",
path_points: [
%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0},
%{"lat" => 31.6, "lon" => -97.35, "dist_km" => 147.5, "elev" => 320.0},
%{"lat" => 30.3, "lon" => -97.7, "dist_km" => 295.0, "elev" => 250.0}
]
}
)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
html = render_click(lv, "toggle_terrain", %{})
assert html =~ "Dist (km)"
end
test "Propagation Analysis renders factor rows and composite tier label", %{conn: conn} do
contact = seed_contact_with_overrides()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Propagation Analysis"
assert html =~ "Contribution"
assert html =~ "Humidity"
assert html =~ "/100"
assert String.match?(html, ~r/(EXCELLENT|GOOD|MARGINAL|POOR|NEGLIGIBLE)/)
end
test "Data Sources card renders HRRR, Terrain, and Elevation blocks", %{conn: conn} do
contact = seed_contact_with_overrides()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Data Sources"
assert html =~ "HRRR Model"
assert html =~ "Terrain Analysis"
assert html =~ "Elevation Profile"
end
test "internal-network conn kicks off enqueue path and transitions pending status", %{conn: conn} do
contact = create_contact()
conn = %{conn | remote_ip: {172, 56, 0, 1}}
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
assert Process.alive?(lv.pid)
assert render(lv) =~ contact.station1
end
test "radar row surfaces max_dbz and coverage_pct in the mechanism block", %{conn: conn} do
contact = create_contact()
Repo.insert!(%ContactCommonVolumeRadar{
contact_id: contact.id,
observed_at: contact.qso_timestamp,
common_volume_km2: 12_500.0,
pixel_count: 800,
rain_pixel_count: 150,
heavy_rain_pixel_count: 25,
max_dbz: 47.5,
mean_dbz: 22.3,
coverage_pct: 18.0
})
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Propagation Mechanism"
assert html =~ "47.5 dBZ"
assert html =~ "Heavy-rain pixels: 25"
end
test "non-owner with a pending edit sees the pending-edit banner", %{conn: conn} do
contact = create_contact()
stranger = user_fixture()
Repo.insert!(%ContactEdit{
contact_id: contact.id,
user_id: stranger.id,
proposed_changes: %{"station2" => "K9EDIT"},
status: :pending
})
conn = log_in_user(conn, stranger)
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "pending edit for this contact awaiting admin review"
end
test "full Atmospheric Profile block renders surface temp/dewpoint/pressure", %{conn: conn} do
contact = seed_contact_with_overrides()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Surface Temp:"
assert html =~ "Surface Dewpoint:"
assert html =~ "Surface Pressure:"
end
test "soundings-empty state shows the 1000 km fallback copy", %{conn: conn} do
contact = create_contact()
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "No soundings found within 1000 km."
end
test "surface-observations queued status shows spinner copy", %{conn: conn} do
contact =
create_contact()
|> Ecto.Changeset.change(weather_status: :queued)
|> Repo.update!()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Surface Observations"
end
test "ducting detected on the HRRR profile shows the Ducting badge", %{conn: conn} do
contact =
seed_contact_with_overrides(
hrrr: %{
ducting_detected: true,
min_refractivity_gradient: -200.0
}
)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "Ducting"
end
test "sounding row with a real station shows the ducting detection badge", %{conn: conn} do
contact = create_contact()
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KFWD",
station_type: "sounding",
name: "Fort Worth",
lat: 32.83,
lon: -97.30
})
Repo.insert!(
Sounding.changeset(%Sounding{}, %{
station_id: station.id,
observed_at: ~U[2026-03-28 12:00:00Z],
profile: [
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
%{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0}
],
level_count: 2,
surface_temp_c: 20.0,
surface_dewpoint_c: 15.0,
surface_refractivity: 340.0,
ducting_detected: true
})
)
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
html = render_async(lv, 2_000)
assert html =~ "KFWD"
assert html =~ "Ducting"
end
property "obs-table sort handler is total over every known field and direction", %{conn: conn} do
contact = create_contact(%{station1: "W5PROP", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
{:ok, station} =
Weather.find_or_create_station(%{
station_code: "KDFW",
station_type: "asos",
name: "DFW Airport",
lat: 32.9,
lon: -97.02
})
Repo.insert!(
SurfaceObservation.changeset(%SurfaceObservation{}, %{
station_id: station.id,
observed_at: contact.qso_timestamp,
temp_f: 70.0,
dewpoint_f: 55.0,
relative_humidity: 55.0,
sea_level_pressure_mb: 1015.0
})
)
known_fields =
~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb zzz_unknown)
field_stream = StreamData.member_of(known_fields)
sequence_gen = StreamData.list_of(field_stream, min_length: 1, max_length: 6)
check all(fields <- sequence_gen, max_runs: 15) do
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
for f <- fields do
html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
assert html =~ "Surface Observations", "sort by #{f} lost the obs table"
end
assert Process.alive?(lv.pid)
end
end
property "soundings-table sort handler tolerates arbitrary field keys", %{conn: conn} do
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
fields_pool =
~w(station_name observed_at surface_temp_c surface_refractivity k_index lifted_index unknown)
sequence_gen =
StreamData.list_of(StreamData.member_of(fields_pool), min_length: 1, max_length: 4)
check all(fields <- sequence_gen, max_runs: 10) do
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
_ = render_async(lv, 2_000)
for f <- fields do
html = render_click(lv, "sort", %{"field" => f, "table" => "soundings"})
assert html =~ "Soundings", "sort by #{f} lost the soundings section"
end
assert Process.alive?(lv.pid)
end
end
end
end