diff --git a/test/microwaveprop/workers/admin_task_worker_test.exs b/test/microwaveprop/workers/admin_task_worker_test.exs new file mode 100644 index 00000000..5682b02e --- /dev/null +++ b/test/microwaveprop/workers/admin_task_worker_test.exs @@ -0,0 +1,393 @@ +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 + # Note: the worker logs "unknown feature time_of_day" here even + # though `time_of_day` is a legitimate public function on + # `Features`. The guard uses `function_exported?/3`, which returns + # false until the target module has been loaded — and + # `Microwaveprop.Backtest.Features` isn't referenced anywhere + # before this branch, so it's not yet loaded in a cold VM. A + # `Code.ensure_loaded?/1` call would fix the guard; see the + # "silently succeeds for an unknown feature" test below for the + # full latent-bug characterization. + 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 "silently succeeds for an unknown feature when the QSO corpus is empty (latent bug)" do + # The worker's guard `if !function_exported?(Features, fun_atom, 3)` + # logs and evaluates `{:error, ...}` but the value is discarded and + # execution falls through. The closure `&apply(Features, fun_atom, + # [&1, &2, &3])` is only invoked if `Backtest.evaluate` has + # contacts to evaluate it against — with an empty QSO corpus (as + # in the sandbox), the undefined function is never called and the + # worker writes an empty report and returns `:ok`. This pins the + # current observable behavior; a proper fix should `{:error, _}` + # early rather than relying on downstream emptiness to mask the + # bug. + with_tmp_cwd(fn tmp -> + args = %{"task" => "backtest", "feature" => "does_not_exist_feature_xyz", "sample_size" => 10} + assert :ok = AdminTaskWorker.perform(%Oban.Job{args: args}) + + # File was still written — guard did not bail out. + assert 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