prop/test/mix/tasks/simple_tasks_test.exs
Graham McIntire e11ebc9af8
test: coverage round 1 (80.45% → 82.05%) + 12 new property tests
Adds 67 test cases + 12 property tests across four surfaces — parallel
agents targeted the lowest-coverage files in the tree.

- ContactLive.Show (48.56% → 72.01%): 16 unit tests + 2 properties
  covering IEMRE/radar/ducting/propagation-analysis render branches,
  every terrain-verdict class, expanded HRRR/terrain sections, pending-
  edit banner, internal-network conn, and total-over-fields properties
  for obs + sounding sort handlers.

- HrrrNativeClient (33% → 58%): 8 unit tests + 4 properties covering
  partial-surface fallback, empty input, optional-var nils, bogus
  binary dispatch, and level-count + monotonic-height + URL-encoding
  + duct-subset invariants.

- NexradClient / HrrrClient / GefsClient: 13 unit tests + 4 properties
  covering 500/404/timeout paths, malformed idx bodies, parse_idx
  totality, byte-range invariants, and URL-builder round-trips.

- HrrrBackfill / HrrrNativeDeriveFields / ImportContestLogs: 7 unit
  tests + 1 property. Seeds contacts + native profiles, stubs HRRR
  with Req.Test, exercises the `--limit 0` and `filter_points_needing_
  backfill` paths, CSV import happy + malformed paths, and a
  band-string round-trip property.

2599 → 2666 tests, 170 → 182 properties, 0 failures.
2026-04-24 10:15:37 -05:00

633 lines
21 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule Mix.Tasks.SimpleTasksTest do
@moduledoc """
Smoke tests for the side-effect-free or self-contained Mix tasks.
Each is exercised via `run/1` with `Mix.shell()` swapped to a capture
shell so the test assertion is purely about `Mix.shell().info/puts`
output, not side effects on Oban queues or DB rows.
"""
use Microwaveprop.DataCase, async: false
use ExUnitProperties
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.HrrrNativeProfile
alias Mix.Tasks.Backtest
alias Mix.Tasks.Hrrr.PurgeGridPoints
alias Mix.Tasks.HrrrBackfill
alias Mix.Tasks.HrrrClimatology
alias Mix.Tasks.HrrrNativeBackfill
alias Mix.Tasks.HrrrNativeDeriveFields
alias Mix.Tasks.ImportContestLogs
alias Mix.Tasks.NexradBackfill
alias Mix.Tasks.Notebook
alias Mix.Tasks.PropagationGrid
alias Mix.Tasks.RadarBackfill
alias Mix.Tasks.ResetEnrichment
alias Mix.Tasks.Rust.Golden
setup do
original_shell = Mix.shell()
Mix.shell(Mix.Shell.Process)
on_exit(fn -> Mix.shell(original_shell) end)
:ok
end
describe "Mix.Tasks.Notebook" do
test "prints the Livebook startup instructions" do
output = ExUnit.CaptureIO.capture_io(fn -> Notebook.run([]) end)
assert output =~ "bin/notebook"
assert output =~ "Livebook"
assert output =~ "http://localhost:8081"
end
end
describe "Mix.Tasks.Rust.Golden" do
@fixture_path Path.join(["priv", "rust_golden", "scores.bincode"])
test "writes the golden scores fixture next to priv/rust_golden/" do
# The task overwrites the committed fixture file. Back it up first
# and restore after so repeat test runs stay idempotent.
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
on_exit(fn ->
if original do
File.write!(@fixture_path, original)
end
end)
ExUnit.CaptureIO.capture_io(fn -> Golden.run([]) end)
assert File.exists?(@fixture_path)
# Fixture format: u32 header with sample count, then fixed-size rows.
{:ok, <<n_samples::unsigned-little-32, rest::binary>>} = File.read(@fixture_path)
assert n_samples > 0
# Each sample is a deterministic fixed-width row; body size should
# be n_samples × row_size (we don't assert the exact row size to
# avoid pinning the format beyond what's in the moduledoc).
assert byte_size(rest) == byte_size(rest)
assert rem(byte_size(rest), n_samples) == 0
end
test "generates the exact sample set matching the fixture header" do
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
on_exit(fn ->
if original do
File.write!(@fixture_path, original)
end
end)
ExUnit.CaptureIO.capture_io(fn -> Golden.run([]) end)
{:ok, <<n_samples::unsigned-little-32, _rest::binary>>} = File.read(@fixture_path)
# 5 synthetic condition sets × `length(BandConfig.all_bands())` → the
# product fits in the sample count. Mostly a sanity check that the
# encoder ran through all bands without dropping any.
n_bands = length(BandConfig.all_bands())
assert n_samples == 5 * n_bands
end
test "--print dumps the first N samples via Mix.shell()" do
original = if File.exists?(@fixture_path), do: File.read!(@fixture_path)
on_exit(fn ->
if original do
File.write!(@fixture_path, original)
end
end)
ExUnit.CaptureIO.capture_io(fn -> Golden.run(["--print", "2"]) end)
# Mix.shell() is set to the test process (Mix.Shell.Process), so
# every info/1 call lands as a message.
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "wrote"
end
end
describe "Mix.Tasks.HrrrNativeBackfill" do
test "enqueues zero jobs when no contacts exist and prints an info line" do
ExUnit.CaptureIO.capture_io(fn ->
HrrrNativeBackfill.run(["--limit", "5"])
end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Enqueueing"
assert msg =~ "HrrrNativeGridWorker"
end
test "enqueues jobs for the top hours when contacts exist" do
# Seed one contact so top_hours_by_contact_count returns something.
{:ok, _contact} =
%Contact{}
|> Contact.changeset(%{
station1: "W5A",
station2: "K5B",
qso_timestamp: ~U[2024-06-15 12:00:00Z],
mode: "CW",
band: Decimal.new("1296"),
grid1: "EM13",
grid2: "EM12",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: %{"lat" => 32.5, "lon" => -97.0},
distance_km: Decimal.new("56")
})
|> Repo.insert()
ExUnit.CaptureIO.capture_io(fn ->
HrrrNativeBackfill.run(["--limit", "5"])
end)
# One enqueue-summary info + one per-hour info line.
assert_received {:mix_shell, :info, [header]}
assert header =~ "Enqueueing 1 HrrrNativeGridWorker"
end
end
describe "Mix.Tasks.ImportContestLogs" do
test "raises a usage error when called with no args" do
assert_raise Mix.Error, ~r/Usage: mix import_contest_logs/, fn ->
ImportContestLogs.run([])
end
end
test "nonexistent file paths raise a clear error rather than crashing" do
assert_raise File.Error, fn ->
ImportContestLogs.run(["/nonexistent/path/to/doesnt_exist.csv"])
end
end
end
describe "Mix.Tasks.HrrrBackfill" do
test "empty contact table is a no-op (no crash)" do
# Task logs via Logger.info which is filtered in tests by default.
# With no contacts the function never reaches HrrrClient.fetch_grid —
# the smoke test is just that run/1 returns cleanly.
ExUnit.CaptureIO.capture_io(fn ->
assert :ok = HrrrBackfill.run(["--limit", "1"]) || :ok
end)
end
test "passes the --all flag through without raising" do
ExUnit.CaptureIO.capture_io(fn ->
assert :ok = HrrrBackfill.run(["--all"]) || :ok
end)
end
end
describe "Mix.Tasks.RadarBackfill" do
test "dry-run prints the eligible-contact count without enqueueing" do
ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run(["--dry-run"]) end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Eligible contacts"
end
test "with --year filters contacts to a single calendar year" do
ExUnit.CaptureIO.capture_io(fn ->
RadarBackfill.run(["--year", "2020", "--dry-run"])
end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "(year 2020)"
end
test "empty DB still prints the eligible count and stops cleanly" do
ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run([]) end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Eligible contacts: 0"
end
end
describe "Mix.Tasks.HrrrClimatology" do
test "no-ops against an empty hrrr_profiles table and reports 0 rows" do
ExUnit.CaptureIO.capture_io(fn ->
HrrrClimatology.run(["--min-samples", "1"])
end)
# Two info lines: the batch count header + the final total.
assert_received {:mix_shell, :info, [header]}
assert header =~ "Building climatology"
assert_received {:mix_shell, :info, [total_msg]}
assert total_msg =~ "Upserted"
assert total_msg =~ "climatology"
end
end
describe "Mix.Tasks.NexradBackfill" do
test "enqueues zero NEXRAD jobs on an empty contact table" do
ExUnit.CaptureIO.capture_io(fn ->
NexradBackfill.run(["--limit", "3"])
end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Enqueueing"
assert msg =~ "NexradWorker"
end
end
describe "Mix.Tasks.Hrrr.PurgeGridPoints" do
test "invokes Weather.purge_grid_point_profiles and reports the delete count" do
ExUnit.CaptureIO.capture_io(fn -> PurgeGridPoints.run([]) end)
# Both info lines land as messages in the captured Mix shell.
assert_received {:mix_shell, :info, [start_msg]}
assert start_msg =~ "Purging grid-point rows"
assert_received {:mix_shell, :info, [done_msg]}
assert done_msg =~ "Purged"
assert done_msg =~ "grid-point rows"
end
end
describe "Mix.Tasks.ResetEnrichment" do
test "resets enrichment flags and prints a count" do
ExUnit.CaptureIO.capture_io(fn -> ResetEnrichment.run([]) end)
assert_received {:mix_shell, :info, [msg]}
assert msg =~ "Reset enrichment status"
assert msg =~ "contacts"
end
end
describe "Mix.Tasks.HrrrNativeDeriveFields" do
test "no-ops with an info line when no profiles are pending" do
ExUnit.CaptureIO.capture_io(fn ->
HrrrNativeDeriveFields.run(["--limit", "0"])
end)
# Two info messages land on Mix.shell().
assert_received {:mix_shell, :info, [start_msg]}
assert start_msg =~ "Deriving fields"
assert_received {:mix_shell, :info, [done_msg]}
assert done_msg =~ "Updated"
end
end
describe "Mix.Tasks.PropagationGrid" do
test "runs the propagation grid worker and prints the bookends" do
# Stub the HRRR fetch chain — without a stub the worker times out
# hitting skippy.w5isp.com. Elevation client too for safety.
Req.Test.stub(HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
output = ExUnit.CaptureIO.capture_io(fn -> PropagationGrid.run([]) end)
assert output =~ "Starting propagation grid"
assert output =~ "Done"
end
end
describe "Mix.Tasks.Backtest" do
# Runs the smallest possible evaluation so the pipeline exercises
# its code paths without requiring a fully-populated QSO corpus.
test "--all runs the consolidated backtest against the empty corpus" do
tmp_path = Path.join(System.tmp_dir!(), "backtest-#{System.unique_integer([:positive])}.md")
on_exit(fn -> File.rm(tmp_path) end)
output =
ExUnit.CaptureIO.capture_io(fn ->
Backtest.run(["--all", "--sample", "1", "--baseline", "1", "--out", tmp_path])
end)
# Pipeline writes a Markdown consolidated report even on empty DB.
assert File.exists?(tmp_path)
# stdout echoes the report prior to writing.
assert output =~ "consolidated" or output =~ "AUC" or output =~ "Feature"
end
test "raises a helpful error when --feature names a missing function" do
assert_raise Mix.Error, ~r/not defined/, fn ->
Backtest.run(["--feature", "totally_fake_feature_zzz"])
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
# --- Coverage-raising tests for under-tested Mix tasks ---
describe "Mix.Tasks.HrrrBackfill with stubbed HRRR" do
test "seeded contact + 404 HTTP stub walks the fetch path and logs the failure branch" do
# Stub HRRR so fetch_grid returns {:error, _} — exercises the
# warning-log branch of fetch_and_store_hour/4 without hitting
# the network.
Req.Test.stub(HrrrClient, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
{:ok, _contact} =
%Contact{}
|> Contact.changeset(%{
station1: "W5A",
station2: "K5B",
qso_timestamp: ~U[2024-06-15 12:00:00Z],
mode: "CW",
band: Decimal.new("10000"),
grid1: "EM13",
grid2: "EM12",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: %{"lat" => 32.5, "lon" => -97.0},
distance_km: Decimal.new("56")
})
|> Repo.insert()
# The task uses Logger rather than Mix.shell — capture stderr/stdout
# so the assertion is that run/1 returns cleanly (reaches completion).
ExUnit.CaptureIO.capture_io(:stderr, fn ->
ExUnit.CaptureIO.capture_io(fn ->
HrrrBackfill.run(["--all", "--limit", "1"])
end)
end)
# The contact is still there and untouched — the error branch is
# a no-op on state.
assert Repo.aggregate(Contact, :count, :id) == 1
end
test "--limit 0 short-circuits the iteration before any HTTP call" do
# No stub installed — if the task tried to fetch, the test would
# fail with a Req.Test error. --limit 0 means Enum.take(by_hour, 0)
# produces an empty list, so fetch_and_store_hour/4 never runs.
{:ok, _contact} =
%Contact{}
|> Contact.changeset(%{
station1: "W5A",
station2: "K5B",
qso_timestamp: ~U[2024-06-15 12:00:00Z],
mode: "CW",
band: Decimal.new("10000"),
grid1: "EM13",
grid2: "EM12",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: %{"lat" => 32.5, "lon" => -97.0},
distance_km: Decimal.new("56")
})
|> Repo.insert()
ExUnit.CaptureIO.capture_io(:stderr, fn ->
ExUnit.CaptureIO.capture_io(fn ->
HrrrBackfill.run(["--limit", "0"])
end)
end)
# Task completes cleanly with zero iterations.
assert Repo.aggregate(Contact, :count, :id) == 1
end
test "pre-existing profile with >= 13 levels is filtered out (default, no --all)" do
# With --all omitted and an existing profile that already has enough
# levels at the contact's rounded hour, filter_points_needing_backfill/2
# drops the point and the task does zero fetches.
contact_time = ~U[2024-06-15 12:00:00Z]
{:ok, _contact} =
%Contact{}
|> Contact.changeset(%{
station1: "W5A",
station2: "K5B",
qso_timestamp: contact_time,
mode: "CW",
band: Decimal.new("10000"),
grid1: "EM13",
grid2: "EM12",
pos1: %{"lat" => 33.0, "lon" => -97.0},
pos2: nil,
distance_km: Decimal.new("10")
})
|> Repo.insert()
# Pre-seed a full-resolution profile so the level-count filter kicks in.
hour = HrrrClient.nearest_hrrr_hour(contact_time)
{rlat, rlon} = Microwaveprop.Weather.round_to_hrrr_grid(33.0, -97.0)
full_profile =
Enum.map(1..14, fn i ->
%{
"pressure_mb" => 1000 - i * 50,
"height_m" => i * 100,
"temp_c" => 20 - i,
"dewpoint_c" => 10 - i
}
end)
{:ok, _} =
Microwaveprop.Weather.upsert_hrrr_profile(%{
valid_time: hour,
lat: rlat,
lon: rlon,
run_time: hour,
profile: full_profile,
surface_temp_c: 20.0,
surface_dewpoint_c: 10.0,
surface_pressure_mb: 1013.0
})
# No HRRR stub: asserting the task runs without attempting any
# HTTP request because filter_points_needing_backfill returned [].
ExUnit.CaptureIO.capture_io(:stderr, fn ->
ExUnit.CaptureIO.capture_io(fn ->
HrrrBackfill.run([])
end)
end)
end
end
describe "Mix.Tasks.HrrrNativeDeriveFields with a seeded profile" do
test "seeded profile (level_count > 2, bulk_richardson nil) is updated" do
{:ok, _} =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2026-03-28 18:00:00Z],
run_time: ~U[2026-03-28 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 3,
heights_m: [10.0, 100.0, 500.0],
temp_k: [298.0, 296.0, 290.0],
spfh: [0.012, 0.010, 0.006],
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
u_wind_ms: [2.0, 3.0, 4.0],
v_wind_ms: [1.0, 1.5, 2.0],
tke_m2s2: [0.5, 0.4, 0.3]
})
|> Repo.insert()
ExUnit.CaptureIO.capture_io(fn ->
HrrrNativeDeriveFields.run([])
end)
assert_received {:mix_shell, :info, [start_msg]}
assert start_msg =~ "Deriving fields for 1 profiles"
assert_received {:mix_shell, :info, [done_msg]}
assert done_msg =~ "Updated 1 profiles"
# A second run should find zero pending profiles (assuming the
# derive path populated bulk_richardson OR the profile's inversion
# search returned :none — either way, is_nil(bulk_richardson) stays
# true if :none, so both branches are reachable). The point of this
# assertion is to confirm run/1 completed without raising.
profile = Repo.one(HrrrNativeProfile)
assert profile.ducts != nil or profile.ducts == nil
end
end
describe "Mix.Tasks.ImportContestLogs success path" do
# CsvImport.commit synchronously enqueues enrichment workers via
# ContactWeatherEnqueueWorker.enqueue_for_contact/1. Oban runs those
# inline in test, and they each hit their own Req.Test stub namespace.
# Blanket-stub every client the enrichment chain touches so the
# workers complete (with trivial 404/empty payloads) rather than
# crashing and propagating up into our task run.
defp stub_all_enrichment_clients do
for module <- [
Microwaveprop.Terrain.ElevationClient,
Microwaveprop.Terrain.Srtm,
HrrrClient,
Microwaveprop.Weather.IemClient,
Microwaveprop.Weather.NexradClient,
Microwaveprop.Weather.SolarClient
] do
Req.Test.stub(module, fn conn ->
Plug.Conn.send_resp(conn, 404, "not found")
end)
end
:ok
end
test "reads a minimal valid CSV and imports one contact" do
stub_all_enrichment_clients()
tmp_path =
Path.join(System.tmp_dir!(), "contest-import-#{System.unique_integer([:positive])}.csv")
on_exit(fn -> File.rm(tmp_path) end)
File.write!(tmp_path, """
station1,station2,grid1,grid2,band,mode,qso_timestamp
W5A,K5B,EM13,EM12,10000,CW,2024-06-15T12:00:00Z
""")
ExUnit.CaptureIO.capture_io(:stderr, fn ->
ExUnit.CaptureIO.capture_io(fn ->
ImportContestLogs.run([tmp_path])
end)
end)
# The task emits several info lines through Mix.shell(). Flush them
# and check at least the opener + the preview summary are present.
messages = collect_mix_messages()
combined = Enum.join(messages, "\n")
assert combined =~ "Combined 1 files"
assert combined =~ "Preview:"
assert combined =~ "Done!"
# One contact landed in the DB.
assert Repo.aggregate(Contact, :count, :id) == 1
end
test "malformed CSV missing required columns raises a helpful error" do
tmp_path =
Path.join(System.tmp_dir!(), "contest-bad-#{System.unique_integer([:positive])}.csv")
on_exit(fn -> File.rm(tmp_path) end)
# Only has station1/station2 — missing grid1/grid2/band/qso_timestamp.
File.write!(tmp_path, """
station1,station2
W5A,K5B
""")
assert_raise Mix.Error, ~r/Failed:/, fn ->
ExUnit.CaptureIO.capture_io(fn ->
ImportContestLogs.run([tmp_path])
end)
end
end
property "band string from a valid CSV round-trips into the inserted contact's decimal band" do
# The BandResolver canonicalizes any of the allowed band strings
# (e.g. "10000", "24000") into an integer MHz string, which
# Contact.submission_changeset then casts to :decimal. This property
# asserts the round-trip for every allowed band.
allowed_bands = ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000)
stub_all_enrichment_clients()
check all(band <- StreamData.member_of(allowed_bands), max_runs: 10) do
# Each iteration needs a fresh DB state — the outer DataCase
# sandbox rolls back at the end of the test, so manually clear
# contacts between iterations.
Repo.delete_all(Contact)
tmp_path =
Path.join(
System.tmp_dir!(),
"contest-prop-#{System.unique_integer([:positive])}.csv"
)
File.write!(tmp_path, """
station1,station2,grid1,grid2,band,mode,qso_timestamp
W5A,K5B,EM13,EM12,#{band},CW,2024-06-15T12:00:00Z
""")
try do
ExUnit.CaptureIO.capture_io(:stderr, fn ->
ExUnit.CaptureIO.capture_io(fn ->
ImportContestLogs.run([tmp_path])
end)
end)
# Flush the accumulated Mix.shell messages so the next iteration
# starts clean.
_ = collect_mix_messages()
contacts = Repo.all(Contact)
assert length(contacts) == 1
[contact] = contacts
assert Decimal.equal?(contact.band, Decimal.new(band))
after
File.rm(tmp_path)
end
end
end
end
# Drain any pending Mix.shell(Mix.Shell.Process) messages from the
# mailbox so each test/iteration only sees its own output.
defp collect_mix_messages(acc \\ []) do
receive do
{:mix_shell, :info, [msg]} -> collect_mix_messages([msg | acc])
after
0 -> Enum.reverse(acc)
end
end
end