prop/test/mix/tasks/simple_tasks_test.exs
Graham McIntire fd976b0cd5
fix: resolve 391 Credo issues across codebase
- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
2026-06-12 13:51:32 -05:00

890 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.RecalibrateScorer
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 x 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 x `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 String.contains?(output, ["consolidated", "AUC", "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
# 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
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