prop/test/microwaveprop/workers/pskr_calibration_worker_test.exs
Graham McIntire 581955bd69
Some checks failed
Build and Push / Build and Push Docker Image (push) Has been cancelled
fix: prevent contacts_dedup_idx collisions in parallel async tests
Create shared ContactsFixtures module with globally-unique qso_timestamp
seconds to prevent unique-constraint violations when async test modules
run in parallel and insert contacts with identical dedup-key columns.
The qso_timestamp column is timestamp(0), so microsecond offsets were
truncated — use System.unique_integer monotonic seconds instead.

Also make count-asserting tests resilient to sandbox-leaked contacts
from prior tests by using >= assertions or status-based checks rather
than exact counts.

Includes automated DateTime.add → DateTime.shift migration from
mix format.
2026-08-04 17:05:16 -05:00

179 lines
6 KiB
Elixir

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.shift(now, hour: -1)
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.shift(now, hour: -offset)
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