test: push line coverage from 78.67% → 80.45%

Adds 30 more test cases (2594 → 2624 tests) targeting the last
low-coverage hot spots.

Notable additions:
- ContactLive.Show fully-hydrated render path (seeds HrrrProfile,
  TerrainProfile, ContactCommonVolumeRadar, Sounding, SurfaceObservation,
  IemreObservation) exercises refresh_computed, build_propagation_analysis,
  build_data_sources, compute_elevation_profile, and the downstream
  render branches they unlock.
- handle_info :terrain_ready and :sounding_fetched paths for same-contact
  and unrelated-contact dispatch.
- Flagged-invalid badge + line-through header style.
- Owner-label button path (Edit vs Suggest Edit).
- NotifyListener init/1 subscribes on schedule; handle_info(:subscribe)
  succeeds against the live Repo.
- RecalibrateScorer runs cleanly against an empty corpus via the
  Recalibrator insufficient-data fallback.
- HrrrNativeClient.extract_native_profiles/2 tolerates malformed input
  without crashing.
This commit is contained in:
Graham McIntire 2026-04-24 08:26:42 -05:00
parent 0f46d4361a
commit 5564b6703d
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 299 additions and 0 deletions

View file

@ -123,6 +123,26 @@ defmodule Microwaveprop.Propagation.NotifyListenerTest do
end
end
describe "lifecycle" do
test "init/1 schedules a :subscribe message and starts with a blank state" do
assert {:ok, %{ref: nil, pid: nil}} = NotifyListener.init([])
assert_receive :subscribe, 1_000
end
test "handle_info(:subscribe, _) succeeds against the real Repo" do
# Happy-path smoke test — the live Repo config is valid in the
# test environment, so Postgrex.Notifications.start_link should
# connect and the handler should store {pid, ref} in state.
state = %{ref: nil, pid: nil}
assert {:noreply, %{pid: pid, ref: ref}} = NotifyListener.handle_info(:subscribe, state)
assert is_pid(pid)
assert is_reference(ref)
# Tidy up the helper connection so it doesn't leak into later tests.
GenServer.stop(pid)
end
end
describe "handle_info/2 raw dispatch" do
test "notification with a malformed payload is tolerated (no crash)" do
# The GenServer callback must not raise even when PostgreSQL emits

View file

@ -203,6 +203,27 @@ defmodule Microwaveprop.Weather.HrrrNativeClientTest do
end
end
describe "extract_native_profiles/2 Elixir fallback" do
# When wgrib2 isn't on the PATH, extract_native_profiles routes to
# the pure-Elixir Extractor and returns a map keyed by the requested
# points. We can't easily validate real GRIB output here, but we can
# drive the dispatch once with a deliberately malformed binary and
# show the client tolerates the Extractor's error without crashing.
test "surfaces the decoder's error when the binary is bogus" do
points = [{32.9, -97.0}]
case HrrrNativeClient.extract_native_profiles(<<0, 1, 2>>, points) do
{:ok, %{} = by_point} ->
# Some decoder paths return an empty ok map — that's still a
# valid contract ({:ok, map()}).
assert Map.has_key?(by_point, {32.9, -97.0}) or map_size(by_point) == 0
{:error, _reason} ->
:ok
end
end
end
describe "duct_messages/0 and duct_byte_ranges/1" do
test "emits one message per (level, duct-var) across the 50-level stack (4 vars)" do
messages = HrrrNativeClient.duct_messages()

View file

@ -780,4 +780,243 @@ defmodule MicrowavepropWeb.ContactLiveTest do
assert Repo.get!(Contact, contact.id).station1 == "W5XD"
end
end
describe "Show fully-hydrated render paths" do
# These tests seed the DB with real-ish enrichment rows for a single
# contact, then drive the LiveView through its async hydration so we
# exercise the derived-analysis helpers (refresh_computed,
# build_propagation_analysis, build_data_sources) and the render
# branches they unlock.
alias Microwaveprop.Propagation.ScoreCache
alias Microwaveprop.Radio.ContactCommonVolumeRadar
alias Microwaveprop.Terrain.TerrainProfile
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Weather.IemreObservation
alias Microwaveprop.Weather.Sounding
alias Microwaveprop.Weather.SurfaceObservation
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)
# Headers / enrichment labels visible when data is present.
assert html =~ contact.station1
assert html =~ "Atmospheric Profile"
assert html =~ "Surface Observations"
assert html =~ "Soundings"
# HRRR metadata exposed by build_hrrr_source.
assert html =~ "1200"
# Terrain verdict surfaced by terrain_summary.
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)
# Simulate the TerrainProfileWorker broadcast — the listener
# recomputes elevation_profile and propagation_analysis and
# re-renders without crashing.
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)
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)
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)
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"
# Header station text gets line-through styling when flagged.
assert html =~ "line-through"
end
test "owner sees an Edit button (not Suggest Edit) for their own contact", %{conn: conn} do
import Microwaveprop.AccountsFixtures
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}")
# Owner gets the direct Edit label, not Suggest Edit.
assert html =~ ">\n Edit\n </button>" or html =~ "Edit"
refute html =~ "Suggest Edit"
end
end
end

View file

@ -309,4 +309,23 @@ defmodule Mix.Tasks.SimpleTasksTest do
end
end
end
describe "Mix.Tasks.RecalibrateScorer" do
alias Mix.Tasks.RecalibrateScorer
# With an empty contacts corpus the training loop short-circuits
# via Recalibrator.fit/1's "insufficient data" branch and returns
# the current BandConfig weights. The task still walks the full
# output path (header + weight-comparison table).
test "empty corpus walks the fallback + comparison path without raising" do
ExUnit.CaptureIO.capture_io(fn ->
RecalibrateScorer.run(["--sample", "1", "--epochs", "1", "--lr", "0.1"])
end)
# First few info lines describe the run config; the task always
# emits at least "Recalibrating scorer weights..." as the opener.
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Recalibrating"
end
end
end