- 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.
889 lines
30 KiB
Elixir
889 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.
|
|
assert %HrrrNativeProfile{} = Repo.one(HrrrNativeProfile)
|
|
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
|