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