prop/test/microwaveprop/workers/gefs_fetch_worker_test.exs
Graham McIntire dc8353a9e9
test: coverage round 4 (84.39% → 84.44%) + 6 new property tests
30 unit tests + 6 property tests across two parallel agents.

- ContactLive.Show + HrrrNativeClient + NexradClient: sort_observations
  + sort_soundings actual ordering per field, closest_observations
  capped-at-5 proximity, nil-pos2 half_dist=0 path, HrrrNativeClient
  scrambled-level density + surface finiteness, NexradClient cache
  population + zero-byte + year-boundary URL rounding. Properties:
  sort_observations preservation, haversine symmetry, build_native
  surface_temp_k finiteness.

- PathLive + Viewshed + GefsFetchWorker + SnmpClient: GPS source
  URL preservation, QRZ 404 surfacing, propagation_updated same-midpoint
  no-op; Viewshed effective_reach_km BLOCKED boundaries + find_reach_km
  zero max_range; GefsFetchWorker 502/400/410 + wind_u/wind_v aliases
  + nil profile; SnmpClient fully-qualified OID + double-dot drop +
  empty poll + unknown radio type. Properties: destination_point
  round-trip < 0.5 km, valid_time = run_time + fh*3600 invariant,
  parse_snmpget_output totality.
2026-04-24 10:32:05 -05:00

402 lines
14 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
describe "perform/1 HTTP classification extra boundaries" do
test "a transient HTTP 502 (bad gateway) returns {:error, _}" do
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
Plug.Conn.send_resp(conn, 502, "Bad Gateway")
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 400 (bad request) returns {:cancel, _}" do
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
Plug.Conn.send_resp(conn, 400, "Bad Request")
end)
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
end
test "a permanent HTTP 410 (gone) returns {:cancel, _}" do
Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn ->
Plug.Conn.send_resp(conn, 410, "Gone")
end)
args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24}
assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args})
end
end
describe "build_profile_attrs/3 extra branches" do
test "accepts wind_u / wind_v keys without the _mps suffix" do
# `build_profile_attrs` checks both `wind_u` and `wind_u_mps` — the
# non-suffixed keys should also populate the row attrs.
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: 4.5,
wind_v: -2.25,
profile: []
}
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
assert attrs.wind_u_mps == 4.5
assert attrs.wind_v_mps == -2.25
end
test "preserves a nil profile list as a no-derivation base 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,
profile: nil
}
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
# nil profile → `SoundingParams.derive([])` is called with the `|| []`
# fallback; empty list returns nil so no derived keys are added.
refute Map.has_key?(attrs, :surface_refractivity)
assert attrs.lat == 32.9
end
end
# Property: build_profile_attrs/3 always emits a valid_time exactly
# `forecast_hour * 3600` seconds after the run_time, for any run/fh pair.
property "build_profile_attrs/3 valid_time is run_time + fh hours exactly" do
check all(
epoch <- StreamData.integer(1_700_000_000..1_800_000_000),
fh <- StreamData.integer(0..384)
) do
{:ok, run_time} = DateTime.from_unix(epoch)
run_time = DateTime.truncate(run_time, :second)
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, fh, profile)
assert DateTime.diff(attrs.valid_time, attrs.run_time, :second) == fh * 3600
assert attrs.forecast_hour == fh
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