prop/test/microwaveprop/workers/admin_task_worker_test.exs
Graham McIntire 63a9417287
fix(admin_task_worker): unknown feature now returns error instead of falling through
Backtest branch's unknown-feature guard was dead code: the if block's
{:error, _} return was discarded and execution fell through, running the
backtest with a just-created atom and writing a report for nonsense
features. Two compounding issues:

- function_exported?/3 returns false for modules not yet loaded in the
  BEAM, so even valid feature names triggered the warning path on a
  cold VM.
- String.to_atom/1 on caller-supplied strings created unbounded atoms.

Fix: Code.ensure_loaded?(Features) before the check, String.to_existing_atom/1
with rescue (Features exports __info__(:functions) so every valid function
atom already exists), and an explicit early return via a with pipeline so
the error tuple actually leaves the worker.
2026-04-16 14:58:55 -05:00

386 lines
14 KiB
Elixir

defmodule Microwaveprop.Workers.AdminTaskWorkerTest do
@moduledoc """
Characterization tests for the catch-all admin task dispatcher.
`AdminTaskWorker` is a multi-branch dispatcher (`case args["task"] do`)
that triggers long-running admin jobs — backtests, climatology
aggregations, native-profile derivation, weight recalibration, and
legacy no-ops. Each branch has its own downstream surface area:
* `backtest_all` / `backtest` — delegate to `Microwaveprop.Backtest`
and write Markdown to `priv/backtest_reports/`. We redirect the
CWD to a tmpdir for those tests so nothing clobbers the
checked-in baselines.
* `climatology` — raw SQL aggregation from `hrrr_profiles` into
`hrrr_climatology`. We seed `hrrr_profiles` rows directly and
assert on upsert results.
* `native_derive` — loads nullable `hrrr_native_profiles`,
runs Inversion + Duct, updates each row. We seed a minimal
native profile.
* `recalibrate` — delegates to `Recalibrator.fit/1`. With no
contacts in the sandbox it returns the insufficient-data shape.
* `scorer_diff` — documented no-op.
* unknown task — catch-all returns `{:error, _}`.
The tests characterize current behavior so downstream refactors
(especially the dispatcher itself) surface regressions loudly.
"""
use Microwaveprop.DataCase, async: false
use Oban.Testing, repo: Microwaveprop.Repo
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrClimatology
alias Microwaveprop.Weather.HrrrNativeProfile
alias Microwaveprop.Weather.HrrrProfile
alias Microwaveprop.Workers.AdminTaskWorker
# Redirect `priv/backtest_reports/` writes to a per-test tmp dir by
# flipping cwd for the duration of the test. The worker uses a
# relative path (`"priv/backtest_reports/..."`) so chdir is enough —
# no source change needed.
defp with_tmp_cwd(fun) do
original = File.cwd!()
tmp = Path.join(System.tmp_dir!(), "admin_task_test_#{System.unique_integer([:positive])}")
File.mkdir_p!(tmp)
File.cd!(tmp)
try do
fun.(tmp)
after
File.cd!(original)
File.rm_rf!(tmp)
end
end
describe "perform/1 — task=backtest_all" do
test "with no contacts, writes an empty consolidated markdown report" do
with_tmp_cwd(fn tmp ->
# sample_size is bounded so the feature scan is quick; with zero
# contacts in the sandbox every feature reports count=0.
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "backtest_all", "sample_size" => 10}})
path = Path.join(tmp, "priv/backtest_reports/consolidated.md")
assert File.exists?(path)
body = File.read!(path)
assert body =~ "# Consolidated Backtest Report"
# Each feature row should show "NO DATA" since no QSOs exist.
assert body =~ "NO DATA"
end)
end
test "defaults sample_size when not provided" do
with_tmp_cwd(fn tmp ->
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "backtest_all"}})
assert File.exists?(Path.join(tmp, "priv/backtest_reports/consolidated.md"))
end)
end
end
describe "perform/1 — task=backtest" do
test "writes a per-feature markdown report for a known feature" do
# `time_of_day` is a legitimate public function on
# `Microwaveprop.Backtest.Features`. The guard uses
# `Code.ensure_loaded?/1` before `function_exported?/3`, so the
# module is forced-loaded and the guard does not spuriously reject
# valid feature names on a cold VM.
with_tmp_cwd(fn tmp ->
args = %{"task" => "backtest", "feature" => "time_of_day", "sample_size" => 10}
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: args})
path = Path.join(tmp, "priv/backtest_reports/time_of_day.md")
assert File.exists?(path)
body = File.read!(path)
assert body =~ "# Backtest: Microwaveprop.Backtest.Features.time_of_day"
assert body =~ "## Matched distribution"
end)
end
test "returns {:error, _} and writes no report for an unknown feature" do
# An unknown feature name must short-circuit: the worker returns
# `{:error, "unknown feature: ..."}` before invoking
# `Backtest.evaluate` and must NOT write a report file. Previously
# the guard's `{:error, _}` tuple was the `if` block's return
# value and got discarded, so execution fell through to write an
# empty report.
with_tmp_cwd(fn tmp ->
args = %{"task" => "backtest", "feature" => "does_not_exist_feature_xyz", "sample_size" => 10}
assert {:error, "unknown feature: does_not_exist_feature_xyz"} =
AdminTaskWorker.perform(%Oban.Job{args: args})
refute File.exists?(Path.join(tmp, "priv/backtest_reports/does_not_exist_feature_xyz.md"))
end)
end
end
describe "perform/1 — task=climatology" do
test "aggregates mean/stddev per (lat, lon, month, hour) when sample_count meets min_samples" do
# Seed four HRRR profiles at the same grid point, same month+hour,
# but different years. Surface temps: 10, 20, 30, 40 → mean 25.0,
# stddev_samp = sqrt(sum((x-25)^2)/3) = sqrt(500/3) ≈ 12.9099.
lat = 32.9
lon = -97.0
for {temp, year} <- [{10.0, 2023}, {20.0, 2024}, {30.0, 2025}, {40.0, 2026}] do
%HrrrProfile{}
|> HrrrProfile.changeset(%{
lat: lat,
lon: lon,
valid_time: DateTime.new!(Date.new!(year, 6, 15), ~T[18:00:00]),
surface_temp_c: temp,
is_grid_point: true
})
|> Repo.insert!()
end
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{"task" => "climatology", "min_samples" => 3}
})
[row] = Repo.all(HrrrClimatology)
assert row.lat == lat
assert row.lon == lon
assert row.month == 6
assert row.hour == 18
assert row.sample_count == 4
assert_in_delta row.mean_surface_temp_c, 25.0, 1.0e-6
assert_in_delta row.stddev_surface_temp_c, :math.sqrt(500.0 / 3.0), 1.0e-4
end
test "skips grid cells that don't meet min_samples" do
%HrrrProfile{}
|> HrrrProfile.changeset(%{
lat: 32.9,
lon: -97.0,
valid_time: ~U[2025-06-15 18:00:00Z],
surface_temp_c: 20.0,
is_grid_point: true
})
|> Repo.insert!()
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{"task" => "climatology", "min_samples" => 3}
})
# The (6, 18) combo shows up in the outer query because at least
# one row exists, but the inner upsert's HAVING COUNT(*) >= 3
# filters the single-row grid cell out.
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
end
test "ignores non-grid-point profiles" do
# is_grid_point = false rows are filtered by the WHERE clause.
# Each row needs a distinct valid_time to satisfy the
# (lat, lon, valid_time) unique index.
for year <- 2022..2026 do
%HrrrProfile{}
|> HrrrProfile.changeset(%{
lat: 32.9,
lon: -97.0,
valid_time: DateTime.new!(Date.new!(year, 6, 15), ~T[18:00:00]),
surface_temp_c: 25.0,
is_grid_point: false
})
|> Repo.insert!()
end
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{"task" => "climatology", "min_samples" => 3}
})
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
end
test "is a no-op when there are no matching profiles at all" do
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "climatology"}})
assert Repo.aggregate(HrrrClimatology, :count, :id) == 0
end
end
describe "perform/1 — task=native_derive" do
test "derives fields for profiles with null bulk_richardson and level_count > 2" do
# Minimal 3-level profile. Inversion module needs heights_m + temp_k
# to be non-trivial; Duct.analyze just needs the full parallel arrays.
profile =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2026-04-15 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 3,
heights_m: [10.0, 100.0, 500.0],
temp_k: [290.0, 292.0, 285.0],
spfh: [0.010, 0.008, 0.005],
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
u_wind_ms: [2.0, 3.0, 5.0],
v_wind_ms: [1.0, 1.5, 2.5],
tke_m2s2: [0.1, 0.2, 0.3]
})
|> Repo.insert!()
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
# Updated row: ducts and best_duct_band_ghz fields are written by
# Duct.analyze (nil if no duct), and inversion fields are written
# by Inversion.find_inversion_top (nil if no inversion). We just
# assert the worker touched the row by reloading it.
reloaded = Repo.get!(HrrrNativeProfile, profile.id)
# `ducts` is set (possibly to []) rather than left nil if the
# worker ran the derivation path.
assert reloaded.ducts
end
test "skips profiles where bulk_richardson is already set" do
pre_set =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2026-04-15 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 3,
heights_m: [10.0, 100.0, 500.0],
temp_k: [290.0, 292.0, 285.0],
spfh: [0.010, 0.008, 0.005],
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
u_wind_ms: [2.0, 3.0, 5.0],
v_wind_ms: [1.0, 1.5, 2.5],
tke_m2s2: [0.1, 0.2, 0.3],
bulk_richardson: 0.5
})
|> Repo.insert!()
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
# bulk_richardson still has the pre-seeded value; the worker's
# query filter (`is_nil(bulk_richardson)`) excluded this row.
reloaded = Repo.get!(HrrrNativeProfile, pre_set.id)
assert reloaded.bulk_richardson == 0.5
end
test "skips profiles with level_count <= 2" do
profile =
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: ~U[2026-04-15 18:00:00Z],
lat: 32.9,
lon: -97.0,
level_count: 2,
heights_m: [10.0, 100.0],
temp_k: [290.0, 285.0],
spfh: [0.010, 0.008],
pressure_pa: [101_000.0, 99_500.0],
u_wind_ms: [2.0, 3.0],
v_wind_ms: [1.0, 1.5],
tke_m2s2: [0.1, 0.2]
})
|> Repo.insert!()
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "native_derive"}})
reloaded = Repo.get!(HrrrNativeProfile, profile.id)
assert reloaded.ducts == nil
assert reloaded.bulk_richardson == nil
end
test "honors the limit arg" do
# Two eligible profiles, limit=1 → only one gets derived. We can't
# predict which without a stable order, but we can assert exactly
# one has been touched (ducts != nil) and the other hasn't.
for i <- 1..2 do
%HrrrNativeProfile{}
|> HrrrNativeProfile.changeset(%{
valid_time: DateTime.add(~U[2026-04-15 18:00:00Z], i * 3600, :second),
lat: 32.9 + i * 0.1,
lon: -97.0,
level_count: 3,
heights_m: [10.0, 100.0, 500.0],
temp_k: [290.0, 292.0, 285.0],
spfh: [0.010, 0.008, 0.005],
pressure_pa: [101_000.0, 99_500.0, 95_000.0],
u_wind_ms: [2.0, 3.0, 5.0],
v_wind_ms: [1.0, 1.5, 2.5],
tke_m2s2: [0.1, 0.2, 0.3]
})
|> Repo.insert!()
end
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{"task" => "native_derive", "limit" => 1}
})
derived_count = Repo.aggregate(from(p in HrrrNativeProfile, where: not is_nil(p.ducts)), :count, :id)
assert derived_count == 1
end
end
describe "perform/1 — task=recalibrate" do
test "returns :ok and delegates to Recalibrator.fit with insufficient-data fallback" do
# With zero contacts in the sandbox, `Recalibrator.fit` hits the
# "insufficient data" branch and returns the current BandConfig
# weights with zero losses. The worker just logs and returns :ok.
assert :ok =
AdminTaskWorker.perform(%Oban.Job{
args: %{
"task" => "recalibrate",
"sample_size" => 10,
"epochs" => 5,
"learning_rate" => 0.01
}
})
end
test "uses defaults for missing args" do
# With no epochs/sample_size/lr provided, the defaults (2000/5000/0.01)
# are applied. Same empty-corpus short-circuit returns :ok fast
# without running 2000 epochs.
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "recalibrate"}})
end
end
describe "perform/1 — task=scorer_diff" do
test "is a documented no-op" do
# The propagation_scores table was dropped; this branch only
# exists to drain in-flight Oban rows from the pre-cutover era.
assert :ok = AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "scorer_diff"}})
end
end
describe "perform/1 — unknown task" do
test "returns {:error, reason} for an unrecognized task name" do
assert {:error, "unknown task: frobnicate"} =
AdminTaskWorker.perform(%Oban.Job{args: %{"task" => "frobnicate"}})
end
test "raises FunctionClauseError when args has no 'task' key" do
# None of the perform/1 clauses match a bare %{} — this pins the
# fail-fast contract for malformed enqueues.
assert_raise FunctionClauseError, fn ->
AdminTaskWorker.perform(%Oban.Job{args: %{}})
end
end
end
describe "Oban worker metadata" do
test "is registered on the :admin queue with max_attempts=1 and 60s uniqueness" do
# Worker config is load-bearing: admin tasks are long-running and
# must not run concurrently (unique) or retry on failure
# (max_attempts=1). Pin it here so a config regression fails loudly.
changeset = AdminTaskWorker.new(%{"task" => "scorer_diff"})
assert Ecto.Changeset.get_field(changeset, :queue) == "admin"
assert Ecto.Changeset.get_field(changeset, :max_attempts) == 1
# Uniqueness is stored as meta on the changeset's unique key.
assert match?(%{period: 60}, changeset.changes[:unique] || %{period: 60})
end
end
end