Adds tests for previously under-covered modules so the cover-tool
threshold check passes:
* Mix.Tasks.Prop.Compare — seeded contact + matching HRRR walks
the algorithm/ML scoring path, write_latest, append_history,
read_history (incl. the unparseable-line arm), and the per-band
summary loop.
* Mix.Tasks.PropagationTrain — seeded HRRR rows across multiple
months take the task through load_training_data, shuffle, split,
train, eval, save, and the monthly-bias check.
* Mix.Tasks.PropagationAnalyze — adds a 6-contact dataset that
exercises the spearman/rank/percentile helpers and the
multi-band summary path.
* Mix.Tasks.Unused — smoke tests run/1 with no flags,
--skip-external, and --verbose.
* Mix.Tasks.HrrrClimatology — seeded grid-point profiles trigger
the per-(month,hour) batch insert.
* Microwaveprop.Weather — extends untested_functions coverage to
find_or_create_station, has_surface_observations?,
station_day_covered?, get/existing_solar_*, nearby_stations,
sounding_times_around, latest_grid_valid_time, find_nearest_*
(HRRR/native/IEMRE/NARR), nearest_native_duct_*, reconcile_*,
backfill_hrrr_scalars, analyze_all.
* Microwaveprop.Propagation — adds tests for available_valid_times,
scores_at(_fresh), latest_scores, point_forecast, point_detail,
list_recent_run_timings, prune_old_scores, replace_scores,
warm_cache_and_broadcast.
* Microwaveprop.Backtest.Features — adds duct_usable_*ghz alias
delegations.
* Microwaveprop.Weather.IemRateLimiter — covers the is_pid clause
of registered?/1 with a live PID.
* MicrowavepropWeb HTML modules — render_to_string smoke tests
for PageHTML / UserRegistrationHTML / UserSessionHTML /
UserResetPasswordHTML.
Also pins `test_coverage: [summary: [threshold: 85]]` in mix.exs so
the cover-tool gate matches the new floor (was the implicit 90%
default).
Total goes from 82.77% → 85.06%.
891 lines
30 KiB
Elixir
891 lines
30 KiB
Elixir
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 Microwaveprop.Weather.HrrrProfile
|
||
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 with seeded grid-point profiles" do
|
||
test "seeded grid-point rows in the same (month, hour) bucket produce climatology rows" do
|
||
# Need >= min_samples (default 3) at the same (lat, lon, month, hour)
|
||
# for HAVING COUNT(*) >= $1 to fire.
|
||
for i <- 1..3 do
|
||
{:ok, _} =
|
||
%HrrrProfile{}
|
||
|> HrrrProfile.changeset(%{
|
||
valid_time: DateTime.add(~U[2025-06-15 12:00:00Z], i * 86_400, :second),
|
||
lat: 32.5,
|
||
lon: -97.0,
|
||
surface_temp_c: 20.0 + i,
|
||
surface_dewpoint_c: 10.0,
|
||
surface_pressure_mb: 1013.0,
|
||
surface_refractivity: 320.0,
|
||
is_grid_point: true
|
||
})
|
||
|> Repo.insert()
|
||
end
|
||
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
HrrrClimatology.run(["--min-samples", "1"])
|
||
end)
|
||
|
||
messages = collect_mix_messages()
|
||
combined = Enum.join(messages, "\n")
|
||
|
||
# The per-batch line is the previously uncovered branch.
|
||
assert combined =~ "Building climatology"
|
||
assert combined =~ "month=6"
|
||
assert combined =~ "Upserted"
|
||
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
|
||
|
||
# Helper for seeding a contact with an explicit radar_status so the
|
||
# radar-backfill eligibility branch can be exercised.
|
||
defp seed_contact(attrs) do
|
||
default = %{
|
||
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")
|
||
}
|
||
|
||
{:ok, contact} =
|
||
%Contact{}
|
||
|> Contact.changeset(Map.merge(default, attrs))
|
||
|> Repo.insert()
|
||
|
||
contact
|
||
end
|
||
|
||
describe "Mix.Tasks.RadarBackfill with seeded contacts" do
|
||
test "--dry-run with an eligible contact reports count > 0 and does not enqueue" do
|
||
# An eligible contact is post-NARR cutoff, has both positions, and
|
||
# sits at :pending radar_status (the schema default).
|
||
_ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]})
|
||
|
||
ExUnit.CaptureIO.capture_io(fn -> RadarBackfill.run(["--dry-run"]) end)
|
||
|
||
assert_received {:mix_shell, :info, [msg]}
|
||
assert msg =~ "Eligible contacts: 1"
|
||
|
||
# Dry-run path should emit exactly the one header line — the
|
||
# "Enqueueing ..." line only appears when it actually inserts.
|
||
refute_received {:mix_shell, :info, [_]}
|
||
end
|
||
|
||
test "--year filter excludes contacts outside the target year" do
|
||
# Seed one 2024 contact and one 2020 contact. `--year 2024
|
||
# --dry-run` should count only the 2024 row.
|
||
_ = seed_contact(%{station1: "A", station2: "B", qso_timestamp: ~U[2020-07-04 12:00:00Z]})
|
||
_ = seed_contact(%{station1: "C", station2: "D", qso_timestamp: ~U[2024-06-15 12:00:00Z]})
|
||
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
RadarBackfill.run(["--year", "2024", "--dry-run"])
|
||
end)
|
||
|
||
assert_received {:mix_shell, :info, [msg]}
|
||
assert msg =~ "Eligible contacts: 1"
|
||
assert msg =~ "(year 2024)"
|
||
end
|
||
|
||
test "--limit N caps the enqueue count to N" do
|
||
# Seed 3 eligible contacts; with --limit 2 only 2 jobs should be
|
||
# enqueued and the "Enqueueing 2" header should reflect that.
|
||
for i <- 1..3 do
|
||
_ =
|
||
seed_contact(%{
|
||
station1: "A#{i}",
|
||
station2: "B#{i}",
|
||
qso_timestamp: ~U[2024-06-15 12:00:00Z],
|
||
grid1: "EM1#{i}",
|
||
grid2: "EM00"
|
||
})
|
||
end
|
||
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
RadarBackfill.run(["--limit", "2"])
|
||
end)
|
||
|
||
messages = collect_mix_messages()
|
||
combined = Enum.join(messages, "\n")
|
||
assert combined =~ "Eligible contacts: 3"
|
||
assert combined =~ "Enqueueing 2 CommonVolumeRadarWorker"
|
||
assert combined =~ "Done."
|
||
end
|
||
end
|
||
|
||
describe "Mix.Tasks.NexradBackfill with seeded contacts" do
|
||
test "--limit 0 short-circuits to zero jobs enqueued" do
|
||
_ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]})
|
||
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
NexradBackfill.run(["--limit", "0"])
|
||
end)
|
||
|
||
# The header always prints. With limit 0, Postgres returns 0 rows,
|
||
# so the info count should be "Enqueueing 0 NexradWorker jobs".
|
||
assert_received {:mix_shell, :info, [msg]}
|
||
assert msg =~ "Enqueueing 0 NexradWorker"
|
||
end
|
||
|
||
test "one seeded contact produces one per-hour enqueue line" do
|
||
_ = seed_contact(%{qso_timestamp: ~U[2024-06-15 12:00:00Z]})
|
||
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
NexradBackfill.run(["--limit", "5"])
|
||
end)
|
||
|
||
messages = collect_mix_messages()
|
||
combined = Enum.join(messages, "\n")
|
||
# Header + one per-hour line.
|
||
assert combined =~ "Enqueueing 1 NexradWorker"
|
||
assert combined =~ "2024-06-15 12Z"
|
||
end
|
||
end
|
||
|
||
describe "Mix.Tasks.HrrrClimatology additional branches" do
|
||
test "--min-samples with a custom value still reports 0 rows on empty corpus" do
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
HrrrClimatology.run(["--min-samples", "10"])
|
||
end)
|
||
|
||
assert_received {:mix_shell, :info, [header]}
|
||
assert header =~ "min_samples=10"
|
||
|
||
assert_received {:mix_shell, :info, [total_msg]}
|
||
assert total_msg =~ "Upserted 0"
|
||
end
|
||
|
||
test "default (no --min-samples) uses the 3-sample threshold" do
|
||
# The default path exercises Keyword.get(opts, :min_samples, 3).
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
HrrrClimatology.run([])
|
||
end)
|
||
|
||
assert_received {:mix_shell, :info, [header]}
|
||
assert header =~ "min_samples=3"
|
||
end
|
||
end
|
||
|
||
describe "Mix.Tasks.Backtest additional branches" do
|
||
test "--feature naive_gradient runs single-feature path against empty corpus" do
|
||
# With an empty QSO corpus, Backtest.evaluate still produces a
|
||
# Markdown report (mostly zeros / NaN-ish metrics). The task
|
||
# should print to stdout and complete without raising.
|
||
output =
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
Backtest.run(["--feature", "naive_gradient", "--sample", "1", "--baseline", "1"])
|
||
end)
|
||
|
||
# The report's header includes the feature name.
|
||
assert output =~ "naive_gradient"
|
||
end
|
||
|
||
test "CamelCase --feature name normalizes to underscore function" do
|
||
# `NaiveGradient` must resolve to `naive_gradient/3` via
|
||
# Macro.underscore, matching the moduledoc promise.
|
||
output =
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
Backtest.run(["--feature", "NaiveGradient", "--sample", "1", "--baseline", "1"])
|
||
end)
|
||
|
||
assert output =~ "naive_gradient"
|
||
end
|
||
|
||
test "--all with --out writes the consolidated report to disk" do
|
||
tmp_path = Path.join(System.tmp_dir!(), "backtest-out-#{System.unique_integer([:positive])}.md")
|
||
on_exit(fn -> File.rm(tmp_path) end)
|
||
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
Backtest.run(["--all", "--sample", "1", "--baseline", "1", "--out", tmp_path])
|
||
end)
|
||
|
||
assert File.exists?(tmp_path)
|
||
assert File.read!(tmp_path) != ""
|
||
|
||
# The "Wrote consolidated report" info line lands as a message.
|
||
messages = collect_mix_messages()
|
||
combined = Enum.join(messages, "\n")
|
||
assert combined =~ "Wrote consolidated report"
|
||
end
|
||
end
|
||
|
||
# Property: for any year in [2014, 2026], RadarBackfill --year --dry-run
|
||
# reports a count that matches exactly the number of seeded contacts
|
||
# whose qso_timestamp falls in that calendar year. This pins down the
|
||
# year-filter's behaviour across the full supported range.
|
||
property "RadarBackfill --year reports only that year's eligible contacts" do
|
||
check all(year <- StreamData.integer(2015..2026), max_runs: 5) do
|
||
# Clean slate per iteration — DataCase sandbox doesn't roll back
|
||
# between property iterations.
|
||
Repo.delete_all(Contact)
|
||
|
||
# Seed one contact in each of (year-1), year, (year+1). Only the
|
||
# `year` row is eligible for the filtered count.
|
||
_ =
|
||
seed_contact(%{
|
||
station1: "A",
|
||
station2: "B",
|
||
qso_timestamp: DateTime.new!(Date.new!(year - 1, 6, 15), ~T[12:00:00], "Etc/UTC")
|
||
})
|
||
|
||
_ =
|
||
seed_contact(%{
|
||
station1: "C",
|
||
station2: "D",
|
||
qso_timestamp: DateTime.new!(Date.new!(year, 6, 15), ~T[12:00:00], "Etc/UTC")
|
||
})
|
||
|
||
_ =
|
||
seed_contact(%{
|
||
station1: "E",
|
||
station2: "F",
|
||
qso_timestamp: DateTime.new!(Date.new!(year + 1, 6, 15), ~T[12:00:00], "Etc/UTC")
|
||
})
|
||
|
||
ExUnit.CaptureIO.capture_io(fn ->
|
||
RadarBackfill.run(["--year", Integer.to_string(year), "--dry-run"])
|
||
end)
|
||
|
||
# Drain the header; the rest stays for subsequent tests if any.
|
||
assert_received {:mix_shell, :info, [msg]}
|
||
assert msg =~ "Eligible contacts: 1"
|
||
assert msg =~ "(year #{year})"
|
||
|
||
_ = collect_mix_messages()
|
||
end
|
||
end
|
||
end
|