defmodule Microwaveprop.Workers.MechanismClassifyWorkerTest do use Microwaveprop.DataCase, async: true alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.ContactCommonVolumeRadar alias Microwaveprop.Workers.MechanismClassifyWorker defp create_contact(attrs \\ %{}) do default = %{ station1: "W5AA", station2: "K5TR", qso_timestamp: ~U[2024-08-15 19:00:00Z], mode: "CW", band: Decimal.new("10000"), grid1: "EM12", grid2: "EM00", pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, distance_km: Decimal.new("295") } {:ok, contact} = %Contact{} |> Contact.changeset(Map.merge(default, attrs)) |> Repo.insert() contact end defp create_radar(contact, attrs) do {:ok, row} = %ContactCommonVolumeRadar{} |> ContactCommonVolumeRadar.changeset( Map.merge( %{ contact_id: contact.id, observed_at: DateTime.to_naive(contact.qso_timestamp), max_dbz: 42.0, mean_dbz: 28.0, pixel_count: 100, rain_pixel_count: 80, heavy_rain_pixel_count: 15, core_pixel_count: 3, coverage_pct: 85.0, common_volume_km2: 1_200.0 }, attrs ) ) |> Repo.insert() row end describe "perform/1" do test "classifies a 10 GHz contact with heavy rain in CV as :rain_scatter" do contact = create_contact() create_radar(contact, %{}) assert :ok = MechanismClassifyWorker.perform(%Oban.Job{ args: %{"contact_id" => contact.id} }) reloaded = Repo.get!(Contact, contact.id) assert reloaded.propagation_mechanism == "rain_scatter" assert reloaded.mechanism_status == :complete assert reloaded.propagation_mechanism_confidence == :high end test "classifies a plain 10 GHz short-path contact with no enrichment as :troposcatter" do contact = create_contact() assert :ok = MechanismClassifyWorker.perform(%Oban.Job{ args: %{"contact_id" => contact.id} }) reloaded = Repo.get!(Contact, contact.id) assert reloaded.propagation_mechanism == "troposcatter" assert reloaded.mechanism_status == :complete assert reloaded.propagation_mechanism_confidence == :low end test "trusts user_declared_prop_mode='EME' over physics" do contact = create_contact(%{ user_declared_prop_mode: "EME", band: Decimal.new("1296"), distance_km: Decimal.new("400") }) assert :ok = MechanismClassifyWorker.perform(%Oban.Job{ args: %{"contact_id" => contact.id} }) reloaded = Repo.get!(Contact, contact.id) assert reloaded.propagation_mechanism == "eme" assert reloaded.propagation_mechanism_confidence == :high end test "short 5 km path classifies as :line_of_sight" do contact = create_contact(%{ pos1: %{"lat" => 32.90, "lon" => -97.0}, pos2: %{"lat" => 32.93, "lon" => -97.0}, distance_km: Decimal.new("5") }) assert :ok = MechanismClassifyWorker.perform(%Oban.Job{ args: %{"contact_id" => contact.id} }) reloaded = Repo.get!(Contact, contact.id) assert reloaded.propagation_mechanism == "line_of_sight" end test "contact without positions is marked unavailable, not failed" do contact = create_contact(%{pos1: nil, pos2: nil}) assert :ok = MechanismClassifyWorker.perform(%Oban.Job{ args: %{"contact_id" => contact.id} }) reloaded = Repo.get!(Contact, contact.id) assert reloaded.mechanism_status == :unavailable end test "missing contact (deleted) returns :ok gracefully" do fake_id = Ecto.UUID.generate() assert :ok = MechanismClassifyWorker.perform(%Oban.Job{ args: %{"contact_id" => fake_id} }) end test "propagates errors from classifier so Oban counts failures + retries, but still marks row :failed" do # Force the classifier to blow up by using a non-integer band — # `Decimal.to_integer/1` in build_inputs raises on fractional values. # This models the "unexpected classifier explosion" that silently # dropped failure counts in prod. contact = create_contact(%{band: Decimal.new("10.5")}) assert {:error, _reason} = MechanismClassifyWorker.perform(%Oban.Job{ args: %{"contact_id" => contact.id} }) reloaded = Repo.get!(Contact, contact.id) assert reloaded.mechanism_status == :failed end test "meteor-shower calendar matches across year boundaries" do # Quadrantids peak Jan 4, ±3 day window. A 6 m 900 km contact on # Dec 31 2025 is 4 days before the Jan 4 2026 peak — should still # pass through shower-active matching (though classification may # be meteor_scatter only if other mechanisms don't fire first). # We assert indirectly: Dec 31 6m 900 km with no duct, no foEs # classifies as :meteor_scatter when the shower window handles # year-crossing correctly, or :troposcatter when it doesn't. contact = create_contact(%{ qso_timestamp: ~U[2025-12-31 10:00:00Z], band: Decimal.new("50"), distance_km: Decimal.new("900"), station1: "W5YR" }) assert :ok = MechanismClassifyWorker.perform(%Oban.Job{ args: %{"contact_id" => contact.id} }) reloaded = Repo.get!(Contact, contact.id) # Dec 31 is 4 days from Jan 4 — outside the ±3 day window, so we # should NOT classify as meteor scatter (Quadrantids) here. This # proves the window math isn't accidentally matching everything. refute reloaded.propagation_mechanism == "meteor_scatter" end end end