Passes `capture_log: true` to ExUnit.start/1 so log output emitted
during a passing test stays in the per-test capture buffer instead of
stdout. Drops ~360 lines of noise from the suite: Postgrex teardown
disconnects, expected worker failures, NexradClient 404 stubs,
NotifyListener warm-skip messages, SNMP poll failures, PromEx tag-
drop warnings, and OTel-handler boot errors. Failing tests still
surface their full logs on report, so debugging is unchanged.
Side-effect fixes:
- LoggerFormatTest opts out via @moduletag capture_log: false — it
patches the :default :logger handler and capture_log hot-swaps the
same handler, so the setup lookup would 404.
- TelemetryTest.start_link/1 unlinks the supervisor before the brutal
kill so the exit signal doesn't propagate to the test process.
- Drop unused `import Ecto.Query` in a top_hours mix-task test.
- Drop unused default-arg `attrs \\ %{}` on create_contact/1 in
contact_map_live_test (every caller passes attrs explicitly).
Remaining noise is ~3 lines from oban_pro vendored-dep @impl warnings
(require an upstream patch) plus rare intermittent async teardowns.
329 lines
11 KiB
Elixir
329 lines
11 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
|
||
|
||
alias Microwaveprop.Propagation.BandConfig
|
||
alias Microwaveprop.Radio.Contact
|
||
alias Microwaveprop.Weather.HrrrClient
|
||
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
|
||
end
|