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, <>} = 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, <>} = 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