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. * 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 — 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" => "recalibrate"}) 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