77 unit tests + 23 property tests across four parallel agents. - ContactLive.Show (72.01% → ~75%): every factor-note branch (humidity/time/td/refractivity/pressure/season/pwat), all propagation_mechanism classifications, apply_admin_edit/owner_edit/ submit_user_edit error paths, internal_network? IP shapes, expanded sounding skew-T, composite-score [0,100] property. - PathLive + BeaconLive.Show + Weather: coordinate-pair resolver, invalid grid, empty-DB edge cases for iemre_for_*, narr_for_*, nearest_native_duct_*, find_nearest_rtma, recent_surface_obs; properties for round_to_hrrr_grid/round_to_iemre_grid/band round-trip/beacon-id URL round-trip. - Viewshed + Duct + SoundingParams: find_reach_km edge cases, destination_point, effective_reach_km fractions, detect_ducts trailing-duct + guard branches; 13 properties including flat- terrain-fully-visible, thicker-duct-lower-freq, M-profile monotonicity, feature-vector deterministic length. - Workers + Mix tasks: GefsFetchWorker 500/429/403 + malformed ISO8601, HrrrNativeGridWorker snap-to-3-decimals + empty DB, IonosphereFetchWorker 404 + non-tabular body, RadarBackfill --dry-run + --year + --limit, NexradBackfill --limit 0, Backtest --feature + --all; year-filter property covering 2015..2026. 170 → 205 properties, 2666 → 2743 tests, 0 failures.
303 lines
10 KiB
Elixir
303 lines
10 KiB
Elixir
defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
|
||
use Microwaveprop.DataCase, async: false
|
||
use ExUnitProperties
|
||
|
||
alias Microwaveprop.Weather
|
||
alias Microwaveprop.Weather.GefsProfile
|
||
alias Microwaveprop.Workers.GefsFetchWorker
|
||
|
||
describe "perform/1" do
|
||
test "cancels on malformed (non-empty, not a seed) args" do
|
||
args = %{"some_unrelated" => "garbage"}
|
||
assert {:cancel, _} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||
end
|
||
|
||
test "cancels when run_hour is not a valid GEFS cycle" do
|
||
args = %{
|
||
"run_time" => "2026-04-18T05:00:00Z",
|
||
"forecast_hour" => 24
|
||
}
|
||
|
||
assert {:cancel, _} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||
end
|
||
end
|
||
|
||
describe "build_profile_attrs/3" do
|
||
test "merges lat/lon/valid_time onto a GefsClient profile map" do
|
||
run_time = ~U[2026-04-18 12:00:00Z]
|
||
|
||
profile = %{
|
||
lat: 32.9,
|
||
lon: -97.0,
|
||
surface_temp_c: 20.0,
|
||
surface_dewpoint_c: 10.0,
|
||
surface_pressure_mb: 1013.0,
|
||
pwat_mm: 20.0,
|
||
wind_u_mps: 2.0,
|
||
wind_v_mps: -1.0,
|
||
cloud_cover_pct: 50.0,
|
||
precip_mm: 0.0,
|
||
profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}]
|
||
}
|
||
|
||
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
|
||
assert attrs.run_time == run_time
|
||
assert attrs.forecast_hour == 24
|
||
assert attrs.valid_time == ~U[2026-04-19 12:00:00Z]
|
||
assert attrs.lat == 32.9
|
||
assert attrs.surface_temp_c == 20.0
|
||
end
|
||
|
||
test "derives SoundingParams-style refractivity metrics when profile is populated" do
|
||
run_time = ~U[2026-04-18 12:00:00Z]
|
||
|
||
profile = %{
|
||
lat: 32.9,
|
||
lon: -97.0,
|
||
surface_temp_c: 20.0,
|
||
surface_dewpoint_c: 15.0,
|
||
surface_pressure_mb: 1013.0,
|
||
pwat_mm: 25.0,
|
||
profile: [
|
||
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0},
|
||
%{"pres" => 925.0, "tmpc" => 18.0, "dwpc" => 12.0, "hght" => 770.0},
|
||
%{"pres" => 850.0, "tmpc" => 14.0, "dwpc" => 8.0, "hght" => 1500.0}
|
||
]
|
||
}
|
||
|
||
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
|
||
assert is_float(attrs.surface_refractivity)
|
||
assert is_float(attrs.min_refractivity_gradient)
|
||
assert is_boolean(attrs.ducting_detected)
|
||
end
|
||
end
|
||
|
||
describe "extended_horizon_hours/0" do
|
||
test "spans f024 through f168 at 6-hour cadence (25 entries)" do
|
||
hours = GefsFetchWorker.extended_horizon_hours()
|
||
assert List.first(hours) == 24
|
||
assert List.last(hours) == 168
|
||
assert length(hours) == 25
|
||
assert Enum.all?(hours, &(rem(&1, 6) == 0))
|
||
end
|
||
end
|
||
|
||
describe "most_recent_available_run/1" do
|
||
test "returns the run cycle 5 or more hours earlier" do
|
||
# 15:00 UTC → 10:00 UTC → snaps to 06Z
|
||
assert GefsFetchWorker.most_recent_available_run(~U[2026-04-18 15:00:00Z]) ==
|
||
~U[2026-04-18 06:00:00Z]
|
||
end
|
||
|
||
test "rolls across midnight when now is near 04Z" do
|
||
# 04:00 UTC → 23:00 the day before → snaps to 18Z previous day
|
||
assert GefsFetchWorker.most_recent_available_run(~U[2026-04-18 04:00:00Z]) ==
|
||
~U[2026-04-17 18:00:00Z]
|
||
end
|
||
end
|
||
|
||
describe "scoring interop" do
|
||
alias Microwaveprop.Propagation
|
||
alias Microwaveprop.Weather.GefsClient
|
||
|
||
test "a GEFS-shaped profile produces non-empty band scores" do
|
||
raw = %{
|
||
"TMP:2 m above ground" => 293.15,
|
||
"RH:2 m above ground" => 50.0,
|
||
"PRES:surface" => 101_325.0,
|
||
"PWAT:entire atmosphere (considered as a single layer)" => 20.0,
|
||
"UGRD:10 m above ground" => 3.0,
|
||
"VGRD:10 m above ground" => -1.5,
|
||
"TCDC:entire atmosphere" => 40.0,
|
||
"APCP:surface" => 0.0,
|
||
"TMP:1000 mb" => 293.15,
|
||
"RH:1000 mb" => 50.0,
|
||
"HGT:1000 mb" => 110.0,
|
||
"TMP:925 mb" => 288.15,
|
||
"RH:925 mb" => 40.0,
|
||
"HGT:925 mb" => 780.0,
|
||
"TMP:850 mb" => 283.15,
|
||
"RH:850 mb" => 35.0,
|
||
"HGT:850 mb" => 1500.0
|
||
}
|
||
|
||
scores = raw |> GefsClient.build_profile() |> Propagation.score_grid_point(~U[2026-04-20 18:00:00Z], 32.9, -97.0)
|
||
|
||
assert is_list(scores)
|
||
assert scores != []
|
||
|
||
score = hd(scores)
|
||
assert is_integer(score.band_mhz)
|
||
assert is_integer(score.score)
|
||
assert score.score >= 0
|
||
assert score.score <= 100
|
||
end
|
||
end
|
||
|
||
describe "most_recent_available_run/1 (further cases)" do
|
||
test "is always 5+ hours older than the input time, on a GEFS cycle" do
|
||
for iso <- ~w(2026-04-18T00:00:00Z 2026-04-18T06:00:00Z 2026-04-18T11:59:59Z) do
|
||
{:ok, now, _} = DateTime.from_iso8601(iso)
|
||
run = GefsFetchWorker.most_recent_available_run(now)
|
||
assert DateTime.diff(now, run, :second) >= 5 * 3600
|
||
assert run.hour in [0, 6, 12, 18]
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "perform/1 seed path (empty args)" do
|
||
test "empty-args job enqueues one GefsFetchWorker per extended_horizon hour" do
|
||
Oban.Testing.with_testing_mode(:manual, fn ->
|
||
assert :ok = GefsFetchWorker.perform(%Oban.Job{args: %{}})
|
||
|
||
# 25 × f024..f168 at 6-hour steps.
|
||
jobs =
|
||
Microwaveprop.Repo.all(
|
||
Ecto.Query.from(j in Oban.Job, where: j.worker == "Microwaveprop.Workers.GefsFetchWorker")
|
||
)
|
||
|
||
# Seeder enqueues one job per forecast hour (plus the run-time row
|
||
# per cycle). Count should equal extended_horizon_hours length.
|
||
assert length(jobs) == length(GefsFetchWorker.extended_horizon_hours())
|
||
end)
|
||
end
|
||
end
|
||
|
||
describe "perform/1 error handling via a stubbed GefsClient" do
|
||
# Driving the real perform/1 through an HTTP stub ends up calling
|
||
# out through the GefsClient module. A 503 from NOMADS is classified
|
||
# as transient (→ {:error, reason}) while a 404 is permanent (→
|
||
# {:cancel, reason}). These exercise handle_error/2 end-to-end.
|
||
|
||
test "a transient HTTP 503 returns {:error, _} so Oban retries the job" do
|
||
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
|
||
Plug.Conn.send_resp(conn, 503, "Service Unavailable")
|
||
end)
|
||
|
||
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
|
||
|
||
assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||
end
|
||
|
||
test "a permanent 404 returns {:cancel, _} so Oban drops the job" do
|
||
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
|
||
Plug.Conn.send_resp(conn, 404, "Not Found")
|
||
end)
|
||
|
||
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
|
||
|
||
assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||
end
|
||
end
|
||
|
||
describe "storing profiles" do
|
||
test "round-trips a full batch through the context" do
|
||
run_time = ~U[2026-04-18 12:00:00Z]
|
||
fh = 24
|
||
valid_time = DateTime.add(run_time, fh * 3600, :second)
|
||
|
||
batch = [
|
||
%{
|
||
run_time: run_time,
|
||
forecast_hour: fh,
|
||
valid_time: valid_time,
|
||
lat: 32.9,
|
||
lon: -97.0,
|
||
surface_temp_c: 20.0,
|
||
surface_dewpoint_c: 10.0,
|
||
surface_pressure_mb: 1013.0,
|
||
pwat_mm: 20.0,
|
||
profile: []
|
||
},
|
||
%{
|
||
run_time: run_time,
|
||
forecast_hour: fh,
|
||
valid_time: valid_time,
|
||
lat: 33.0,
|
||
lon: -97.0,
|
||
surface_temp_c: 19.5,
|
||
surface_dewpoint_c: 9.5,
|
||
surface_pressure_mb: 1012.5,
|
||
pwat_mm: 19.0,
|
||
profile: []
|
||
}
|
||
]
|
||
|
||
{count, _} = Weather.upsert_gefs_profiles_batch(batch)
|
||
assert count == 2
|
||
|
||
persisted = Microwaveprop.Repo.all(GefsProfile)
|
||
assert length(persisted) == 2
|
||
end
|
||
end
|
||
|
||
describe "perform/1 HTTP classification branches" do
|
||
test "a transient HTTP 500 returns {:error, _} so Oban retries" do
|
||
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
|
||
Plug.Conn.send_resp(conn, 500, "Internal Server Error")
|
||
end)
|
||
|
||
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
|
||
assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||
end
|
||
|
||
test "a transient HTTP 429 (rate-limit) returns {:error, _}" do
|
||
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
|
||
Plug.Conn.send_resp(conn, 429, "Too Many Requests")
|
||
end)
|
||
|
||
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
|
||
assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||
end
|
||
|
||
test "a permanent HTTP 403 returns {:cancel, _}" do
|
||
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
|
||
Plug.Conn.send_resp(conn, 403, "Forbidden")
|
||
end)
|
||
|
||
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
|
||
assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||
end
|
||
|
||
test "malformed run_time ISO8601 returns {:cancel, :invalid_args}" do
|
||
args = %{"run_time" => "not-a-timestamp", "forecast_hour" => 24}
|
||
assert {:cancel, :invalid_args} = GefsFetchWorker.perform(%Oban.Job{args: args})
|
||
end
|
||
end
|
||
|
||
describe "build_profile_attrs/3 with empty profile" do
|
||
test "an empty profile list leaves derived refractivity fields absent" do
|
||
run_time = ~U[2026-04-18 12:00:00Z]
|
||
|
||
profile = %{
|
||
lat: 32.9,
|
||
lon: -97.0,
|
||
surface_temp_c: 20.0,
|
||
surface_dewpoint_c: 10.0,
|
||
surface_pressure_mb: 1013.0,
|
||
pwat_mm: 20.0,
|
||
profile: []
|
||
}
|
||
|
||
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
|
||
# With an empty levels list, SoundingParams.derive returns nil and the
|
||
# base attrs map is returned untouched — no refractivity keys.
|
||
refute Map.has_key?(attrs, :surface_refractivity)
|
||
refute Map.has_key?(attrs, :ducting_detected)
|
||
end
|
||
end
|
||
|
||
# Property: the seeder's target run is always a valid GEFS cycle at
|
||
# least 5 hours older than the input instant, regardless of time of day.
|
||
property "most_recent_available_run/1 snaps to a valid 00/06/12/18Z cycle" do
|
||
check all(epoch <- StreamData.integer(1_700_000_000..1_800_000_000)) do
|
||
{:ok, now} = DateTime.from_unix(epoch)
|
||
run = GefsFetchWorker.most_recent_available_run(now)
|
||
|
||
assert run.hour in [0, 6, 12, 18]
|
||
assert run.minute == 0
|
||
assert run.second == 0
|
||
assert DateTime.diff(now, run, :second) >= 5 * 3600
|
||
end
|
||
end
|
||
end
|