defmodule Microwaveprop.Workers.PskrCalibrationWorkerTest do use Microwaveprop.DataCase, async: true use Oban.Testing, repo: Microwaveprop.Repo alias Microwaveprop.Pskr.CalibrationSample alias Microwaveprop.Pskr.SpotHourly alias Microwaveprop.Repo alias Microwaveprop.Weather.HrrrProfile alias Microwaveprop.Workers.PskrCalibrationWorker defp insert_spot!(hour_utc, opts \\ []) do sender_grid = Keyword.get(opts, :sender_grid, "EM12KL") midpoint_lat = Keyword.get(opts, :midpoint_lat, 33.0) midpoint_lon = Keyword.get(opts, :midpoint_lon, -97.0) {:ok, _} = %SpotHourly{} |> SpotHourly.changeset(%{ hour_utc: hour_utc, band: "10000", sender_grid: sender_grid, receiver_grid: "DM43ST", spot_count: 1, midpoint_lat: midpoint_lat, midpoint_lon: midpoint_lon, distance_km: 200.0, modes: ["FT8"] }) |> Repo.insert() end defp truncate_to_hour(dt), do: %{dt | minute: 0, second: 0, microsecond: {0, 0}} describe "perform/1 with explicit hour_utc" do test "samples just that hour" do hour = ~U[2026-05-04 18:00:00Z] insert_spot!(hour) assert :ok = perform_job(PskrCalibrationWorker, %{"hour_utc" => DateTime.to_iso8601(hour)}) [sample] = Repo.all(CalibrationSample) assert sample.hour_utc == hour end test "succeeds when the hour has no spots" do assert :ok = perform_job(PskrCalibrationWorker, %{"hour_utc" => "2026-05-04T03:00:00Z"}) assert Repo.aggregate(CalibrationSample, :count) == 0 end end describe "perform/1 default rolling window" do test "samples both the current hour and the previous hour" do # Frequent processing means a single fire must cover the hour # boundary: spots in the just-ended hour and spots in the # in-progress hour both get sample rows. Otherwise spots that # arrive in the last few minutes of an hour wait up to a full # cron interval before they're sampled. now = truncate_to_hour(DateTime.utc_now()) previous = DateTime.add(now, -3600, :second) insert_spot!(now) insert_spot!(previous, sender_grid: "EM10AA") assert :ok = perform_job(PskrCalibrationWorker, %{}) hours = CalibrationSample |> Repo.all() |> Enum.map(& &1.hour_utc) |> Enum.sort() assert hours == [previous, now] end test "is idempotent — re-running merges into the same rows" do now = truncate_to_hour(DateTime.utc_now()) insert_spot!(now) assert :ok = perform_job(PskrCalibrationWorker, %{}) first_count = Repo.aggregate(CalibrationSample, :count) assert :ok = perform_job(PskrCalibrationWorker, %{}) assert Repo.aggregate(CalibrationSample, :count) == first_count end end describe "perform/1 with explicit lookback_hours" do test "extends the rolling window to backfill recent gaps" do # Operator sets lookback_hours=3 to recover from a short pod # outage. The worker fires once and produces samples for the # current hour plus the three before it. now = truncate_to_hour(DateTime.utc_now()) Enum.each(0..3, fn offset -> hour = DateTime.add(now, -offset * 3600, :second) insert_spot!(hour, sender_grid: "EM1#{offset}AA") end) assert :ok = perform_job(PskrCalibrationWorker, %{"lookback_hours" => 3}) assert Repo.aggregate(CalibrationSample, :count) == 4 end end describe "follow-up scheduling" do # Self-healing pipeline: when the sampler reports cells whose HRRR # data isn't yet in the DB (so it just enqueued hrrr_fetch_tasks # for the Rust hrrr-point-worker to drain), the worker schedules # a re-run of itself for that exact hour ~10 min later. By that # point the Rust worker has typically drained the batch, and the # follow-up re-run UPSERTs the samples with non-NULL HRRR. test "schedules a follow-up for hours with missing HRRR data" do Oban.Testing.with_testing_mode(:manual, fn -> hour = ~U[2026-05-04 18:00:00Z] insert_spot!(hour) assert :ok = perform_job(PskrCalibrationWorker, %{"hour_utc" => DateTime.to_iso8601(hour)}) assert_enqueued( worker: PskrCalibrationWorker, args: %{ "hour_utc" => DateTime.to_iso8601(hour), "_follow_up" => true } ) end) end test "does not schedule a follow-up when no cells are missing HRRR" do # Plant the HRRR profile so the sampler finds it on first pass. hour = ~U[2026-05-04 18:00:00Z] insert_spot!(hour) {:ok, _} = %HrrrProfile{} |> HrrrProfile.changeset(%{ valid_time: hour, lat: 33.0, lon: -97.0, run_time: hour, surface_temp_c: 20.0, surface_dewpoint_c: 15.0, pwat_mm: 25.0, surface_pressure_mb: 1013.0, min_refractivity_gradient: -100.0, hpbl_m: 500.0, ducting_detected: false, profile: [] }) |> Repo.insert() Oban.Testing.with_testing_mode(:manual, fn -> assert :ok = perform_job(PskrCalibrationWorker, %{"hour_utc" => DateTime.to_iso8601(hour)}) refute_enqueued(worker: PskrCalibrationWorker) end) end test "a follow-up re-run does not chain another follow-up (loop terminator)" do # Even if the follow-up re-run STILL finds missing HRRR (e.g. # the Rust worker is slow), the `_follow_up: true` sentinel # prevents chaining a second follow-up. The rolling-window # cron is the safety net for that case. Oban.Testing.with_testing_mode(:manual, fn -> hour = ~U[2026-05-04 18:00:00Z] insert_spot!(hour) assert :ok = perform_job(PskrCalibrationWorker, %{ "hour_utc" => DateTime.to_iso8601(hour), "_follow_up" => true }) refute_enqueued(worker: PskrCalibrationWorker) end) end end end