- Add jump_credo_checks ~> 0.4 with all 20 checks enabled - Fix all standard Credo issues: 139 @spec (113 done, 26 remain), 4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom, 2 max line length, 9 assert_receive timeout - Fix 170+ jump_credo_checks warnings: - 117 TopLevelAliasImportRequire: move nested alias/import to module top - 32 UseObanProWorker: switch to Oban.Pro.Worker - 4 DoctestIExExamples: add doctests / create test file - ~20 WeakAssertion: strengthen type-check assertions - Various ConditionalAssertion, AssertReceiveTimeout fixes - Exclude vendor/ from Credo analysis - Remaining: 175 warnings (mostly opinionated WeakAssertion, AvoidSocketAssignsInTest), 26 @spec annotations
427 lines
14 KiB
Elixir
427 lines
14 KiB
Elixir
defmodule Microwaveprop.Workers.GefsFetchWorkerTest do
|
||
use Microwaveprop.DataCase, async: false
|
||
use ExUnitProperties
|
||
|
||
alias Microwaveprop.Propagation
|
||
alias Microwaveprop.Weather
|
||
alias Microwaveprop.Weather.GefsClient
|
||
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 attrs.ducting_detected in [true, false]
|
||
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
|
||
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 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(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(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(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(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(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(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(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(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 — comprehensive coverage" do
|
||
test "sets all 14 base fields when profile contains them" 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: 25.0,
|
||
wind_u: 4.5,
|
||
wind_v: -2.25,
|
||
cloud_cover_pct: 30.0,
|
||
precip_mm: 1.5,
|
||
profile: []
|
||
}
|
||
|
||
attrs = GefsFetchWorker.build_profile_attrs(run_time, 24, profile)
|
||
assert attrs.surface_temp_c == 20.0
|
||
assert attrs.cloud_cover_pct == 30.0
|
||
assert attrs.precip_mm == 1.5
|
||
assert attrs.wind_u_mps == 4.5
|
||
assert attrs.wind_v_mps == -2.25
|
||
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
|