prop/test/mix/tasks/prop_compare_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

179 lines
5.8 KiB
Elixir

defmodule Mix.Tasks.Prop.CompareTest do
@moduledoc """
Coverage smoke test for `mix prop.compare`. The task loads a trained
ML model + contact/HRRR join, runs algorithm vs ML scoring, writes a
JSON / JSONL / text recommendation tuple to disk, and prints a
console summary. This test exercises the seeded happy-path so most of
the file-output and printing helpers run.
"""
use Microwaveprop.DataCase, async: false
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Weather.HrrrProfile
alias Mix.Tasks.Prop.Compare
setup do
original_shell = Mix.shell()
Mix.shell(Mix.Shell.Process)
on_exit(fn -> Mix.shell(original_shell) end)
output_dir = Path.join(System.tmp_dir!(), "prop_compare_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(output_dir)
on_exit(fn -> File.rm_rf!(output_dir) end)
%{output_dir: output_dir}
end
defp seed_contact_and_hrrr(opts \\ []) do
qso_ts =
Keyword.get(opts, :qso_timestamp, DateTime.utc_now() |> DateTime.add(-3600, :second) |> DateTime.truncate(:second))
valid_time = qso_ts |> DateTime.truncate(:second) |> Map.put(:minute, 0) |> Map.put(:second, 0)
band = Keyword.get(opts, :band, "10000")
suffix = Keyword.get(opts, :suffix, "")
midlat_offset = Keyword.get(opts, :midlat_offset, 0.0)
pos1 = %{"lat" => 33.0 + midlat_offset, "lon" => -97.0}
pos2 = %{"lat" => 32.5 + midlat_offset, "lon" => -97.5}
{:ok, contact} =
%Contact{}
|> Contact.changeset(%{
station1: "W5A#{suffix}",
station2: "K5B#{suffix}",
qso_timestamp: valid_time,
mode: "CW",
band: Decimal.new(band),
grid1: "EM13",
grid2: "EM12",
pos1: pos1,
pos2: pos2,
distance_km: Decimal.new("60")
})
|> Repo.insert()
# Midpoint snaps to nearest 1/8 degree.
midlat = 32.75 + midlat_offset
midlon = -97.25
%HrrrProfile{}
|> HrrrProfile.changeset(%{
valid_time: valid_time,
lat: midlat,
lon: midlon,
surface_temp_c: 22.0,
surface_dewpoint_c: 14.0,
surface_pressure_mb: 1012.0,
surface_refractivity: 320.0,
min_refractivity_gradient: -100.0,
hpbl_m: 1200.0,
pwat_mm: 25.0,
ducting_detected: false
})
|> Repo.insert(on_conflict: :nothing)
contact
end
describe "Mix.Tasks.Prop.Compare.run/1" do
test "pre-existing history.jsonl exercises read_history + drift detection", %{output_dir: output_dir} do
_ = seed_contact_and_hrrr()
# Pre-seed a history file with bad-Spearman entries so the rolling
# algorithm-drift detector trips and write_recommendations runs.
history_path = Path.join(output_dir, "history.jsonl")
history_lines =
for _ <- 1..6 do
Jason.encode!(%{
"date" => DateTime.utc_now() |> DateTime.add(-3600 * 24, :second) |> DateTime.to_iso8601(),
"n_samples" => 100,
"overall" => %{"alg_distance_spearman" => 0.5, "alg_ml_rmse" => 5.0},
"by_band" => %{}
})
end
File.write!(history_path, Enum.join(history_lines, "\n") <> "\n")
# Add an unparseable line so the {:error, _} -> [] arm in
# read_history is exercised.
File.write!(history_path, "not-valid-json\n", [:append])
try do
ExUnit.CaptureIO.capture_io(fn ->
Compare.run(["--days", "1", "--samples", "10", "--output", output_dir])
end)
catch
:exit, _ -> :ok
end
# The history file remains; new entries appended.
assert File.exists?(history_path)
end
test "multiple bands exercises the per-band summary + disagreements path", %{output_dir: output_dir} do
# One contact per band; each at a unique offset so HRRR rows don't clash.
for {band, i} <- Enum.with_index(["10000", "24000", "47000", "75000"]) do
for j <- 1..3 do
_ = seed_contact_and_hrrr(band: band, suffix: "B#{i}#{j}", midlat_offset: i * 0.5 + j * 0.125)
end
end
try do
ExUnit.CaptureIO.capture_io(fn ->
Compare.run(["--days", "1", "--samples", "100", "--output", output_dir])
end)
catch
:exit, _ -> :ok
end
latest_path = Path.join(output_dir, "latest.json")
if File.exists?(latest_path) do
decoded = latest_path |> File.read!() |> Jason.decode!()
assert map_size(decoded["by_band"]) > 0
# 4 bands keyed by their MHz string.
assert Enum.any?(["10000", "24000", "47000", "75000"], &Map.has_key?(decoded["by_band"], &1))
end
end
test "seeded contact + matching HRRR walks the scoring + write path", %{output_dir: output_dir} do
_ = seed_contact_and_hrrr()
try do
ExUnit.CaptureIO.capture_io(fn ->
Compare.run(["--days", "1", "--samples", "10", "--output", output_dir])
end)
catch
:exit, _ -> :ok
end
# Each successful run writes latest.json + history.jsonl. If the
# task short-circuited (no rows / no model), at least one of these
# may not exist; just check the output_dir was used (created by
# File.mkdir_p! at task start).
assert File.dir?(output_dir)
# If model + data both lined up, the JSON sits on disk.
latest_path = Path.join(output_dir, "latest.json")
if File.exists?(latest_path) do
decoded = latest_path |> File.read!() |> Jason.decode!()
assert is_integer(decoded["n_samples"])
assert map_size(decoded["overall"]) > 0
end
history_path = Path.join(output_dir, "history.jsonl")
if File.exists?(history_path) do
# JSONL: one JSON object per line.
history_path
|> File.read!()
|> String.split("\n", trim: true)
|> Enum.each(fn line ->
assert {:ok, _} = Jason.decode(line)
end)
end
end
end
end