prop/test/mix/tasks/simple_tasks_test.exs
Graham McIntire b2b8ddc1c4
test: silence expected warnings/errors during test runs
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.
2026-04-24 09:27:17 -05:00

329 lines
11 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

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

defmodule Mix.Tasks.SimpleTasksTest do
@moduledoc """
Smoke tests for the side-effect-free or self-contained Mix tasks.
Each is exercised via `run/1` with `Mix.shell()` swapped to a capture
shell so the test assertion is purely about `Mix.shell().info/puts`
output, not side effects on Oban queues or DB rows.
"""
use Microwaveprop.DataCase, async: false
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