test: push coverage 79.64% -> 80.04% across multiple modules
New test files: RoverPlanning.Station, LiveStashGuard, RebatchAsos, RoverMissionReconcileWorker, RoverPathProfileWorker. Extended existing suites for PathCompute (resolve_location, build_ionosphere_readout), HrdpsClient (DEPR fallback, missing-level skip), NexradClient (fetch_rain_cells HTTP error paths), UserHomeQthLookupWorker (error + Maidenhead-fallback paths), RoverPlanning.Path (statuses/0 + schema). 3512 tests passing.
This commit is contained in:
parent
06a54075f8
commit
f644d1796f
10 changed files with 612 additions and 2 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
defmodule Microwaveprop.Propagation.PathComputeTest do
|
defmodule Microwaveprop.Propagation.PathComputeTest do
|
||||||
use ExUnit.Case, async: true
|
use Microwaveprop.DataCase, async: false
|
||||||
use ExUnitProperties
|
use ExUnitProperties
|
||||||
|
|
||||||
alias Microwaveprop.Propagation.BandConfig
|
alias Microwaveprop.Propagation.BandConfig
|
||||||
|
|
@ -96,6 +96,42 @@ defmodule Microwaveprop.Propagation.PathComputeTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "resolve_location/1" do
|
||||||
|
test "parses a coordinate pair" do
|
||||||
|
assert {:ok, %{lat: 32.9, lon: -96.8}} = PathCompute.resolve_location("32.9, -96.8")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "parses a Maidenhead grid" do
|
||||||
|
assert {:ok, %{kind: :grid, label: "EM12"}} = PathCompute.resolve_location("EM12")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "blank input returns the location-required error" do
|
||||||
|
assert {:error, "Location is required"} = PathCompute.resolve_location("")
|
||||||
|
assert {:error, "Location is required"} = PathCompute.resolve_location(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "build_ionosphere_readout/4" do
|
||||||
|
test "returns nil for non-ionosphere bands (microwave)" do
|
||||||
|
assert PathCompute.build_ionosphere_readout(10_000, 32.9, -96.8, 100.0) == nil
|
||||||
|
assert PathCompute.build_ionosphere_readout(24_000, 32.9, -96.8, 100.0) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil for bands the readout does not handle (e.g. 902 MHz)" do
|
||||||
|
# The function only opens up for [50, 144, 222, 432]; 902 falls
|
||||||
|
# through to the catch-all clause.
|
||||||
|
assert PathCompute.build_ionosphere_readout(902, 32.9, -96.8, 100.0) == nil
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil when band is supported but no ionosphere data is available" do
|
||||||
|
# 6m and 2m hit the `Ionosphere.nearest_foes/2` path; with an empty
|
||||||
|
# test DB the function returns `{:error, :no_data}` and the
|
||||||
|
# readout falls through to the catch-all `nil` arm.
|
||||||
|
assert PathCompute.build_ionosphere_readout(50, 32.9, -96.8, 100.0) == nil
|
||||||
|
assert PathCompute.build_ionosphere_readout(144, 32.9, -96.8, 100.0) == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "compute_power_budget/2" do
|
describe "compute_power_budget/2" do
|
||||||
test "computes rx power and margins" do
|
test "computes rx power and margins" do
|
||||||
loss = %{total: 140.0, fspl: 130.0, o2: 3.0, h2o: 5.0, rain: 0.0, diffraction: 2.0}
|
loss = %{total: 140.0, fspl: 130.0, o2: 3.0, h2o: 5.0, rain: 0.0, diffraction: 2.0}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,26 @@ defmodule Microwaveprop.RoverPlanning.PathTest do
|
||||||
|
|
||||||
alias Microwaveprop.RoverPlanning.Path
|
alias Microwaveprop.RoverPlanning.Path
|
||||||
|
|
||||||
|
describe "statuses/0" do
|
||||||
|
test "returns the full enum list" do
|
||||||
|
assert Path.statuses() == [:pending, :computing, :complete, :failed]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "schema metadata" do
|
||||||
|
test "has the expected fields" do
|
||||||
|
fields = Path.__schema__(:fields)
|
||||||
|
assert :band_mhz in fields
|
||||||
|
assert :status in fields
|
||||||
|
assert :result in fields
|
||||||
|
assert :error in fields
|
||||||
|
assert :computed_at in fields
|
||||||
|
assert :mission_id in fields
|
||||||
|
assert :rover_location_id in fields
|
||||||
|
assert :station_id in fields
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "changeset/2" do
|
describe "changeset/2" do
|
||||||
test "valid attrs produce a valid changeset" do
|
test "valid attrs produce a valid changeset" do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
|
|
@ -69,6 +89,19 @@ defmodule Microwaveprop.RoverPlanning.PathTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "accepts :computing status" do
|
||||||
|
attrs = %{
|
||||||
|
mission_id: Ecto.UUID.generate(),
|
||||||
|
rover_location_id: Ecto.UUID.generate(),
|
||||||
|
station_id: Ecto.UUID.generate(),
|
||||||
|
band_mhz: 10_000,
|
||||||
|
status: :computing
|
||||||
|
}
|
||||||
|
|
||||||
|
changeset = Path.changeset(%Path{}, attrs)
|
||||||
|
assert changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
test "accepts optional result and error fields" do
|
test "accepts optional result and error fields" do
|
||||||
attrs = %{
|
attrs = %{
|
||||||
mission_id: Ecto.UUID.generate(),
|
mission_id: Ecto.UUID.generate(),
|
||||||
|
|
|
||||||
104
test/microwaveprop/rover_planning/station_test.exs
Normal file
104
test/microwaveprop/rover_planning/station_test.exs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
defmodule Microwaveprop.RoverPlanning.StationTest do
|
||||||
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.RoverPlanning.Station
|
||||||
|
|
||||||
|
describe "changeset/2" do
|
||||||
|
test "resolves coordinate input into lat/lon" do
|
||||||
|
changeset = Station.changeset(%Station{}, %{input: "32.9, -96.8"})
|
||||||
|
|
||||||
|
assert changeset.valid?
|
||||||
|
assert get_change(changeset, :lat) == 32.9
|
||||||
|
assert get_change(changeset, :lon) == -96.8
|
||||||
|
end
|
||||||
|
|
||||||
|
test "resolves Maidenhead grid into lat/lon and grid field" do
|
||||||
|
changeset = Station.changeset(%Station{}, %{input: "EM12kx"})
|
||||||
|
|
||||||
|
assert changeset.valid?
|
||||||
|
assert get_change(changeset, :grid) == "EM12KX"
|
||||||
|
assert is_float(get_change(changeset, :lat))
|
||||||
|
assert is_float(get_change(changeset, :lon))
|
||||||
|
end
|
||||||
|
|
||||||
|
test "blank input is invalid" do
|
||||||
|
changeset = Station.changeset(%Station{}, %{input: ""})
|
||||||
|
refute changeset.valid?
|
||||||
|
assert "can't be blank" in errors_on(changeset).input
|
||||||
|
end
|
||||||
|
|
||||||
|
test "missing input is invalid" do
|
||||||
|
changeset = Station.changeset(%Station{}, %{})
|
||||||
|
refute changeset.valid?
|
||||||
|
assert "can't be blank" in errors_on(changeset).input
|
||||||
|
end
|
||||||
|
|
||||||
|
test "out-of-range latitude is rejected" do
|
||||||
|
# Stuff a coordinate input that LocationResolver returns happily
|
||||||
|
# but is out of physical range — validate_number should catch it.
|
||||||
|
changeset = Station.changeset(%Station{}, %{input: "95.0, 0.0"})
|
||||||
|
refute changeset.valid?
|
||||||
|
assert Map.has_key?(errors_on(changeset), :lat)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "out-of-range longitude is rejected" do
|
||||||
|
changeset = Station.changeset(%Station{}, %{input: "0.0, 200.0"})
|
||||||
|
refute changeset.valid?
|
||||||
|
assert Map.has_key?(errors_on(changeset), :lon)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "re-typing input wipes previously resolved fields before re-resolving" do
|
||||||
|
stale = %Station{
|
||||||
|
callsign: "OLDCALL",
|
||||||
|
grid: "FN42",
|
||||||
|
lat: 40.0,
|
||||||
|
lon: -74.0,
|
||||||
|
input: "FN42"
|
||||||
|
}
|
||||||
|
|
||||||
|
changeset = Station.changeset(stale, %{input: "32.9, -96.8"})
|
||||||
|
|
||||||
|
assert changeset.valid?
|
||||||
|
# New coordinate input clears stale callsign/grid…
|
||||||
|
assert get_change(changeset, :callsign) == nil
|
||||||
|
assert get_change(changeset, :grid) == nil
|
||||||
|
# …and replaces lat/lon with the new resolution.
|
||||||
|
assert get_change(changeset, :lat) == 32.9
|
||||||
|
assert get_change(changeset, :lon) == -96.8
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps existing lat/lon when no input change and no new resolution needed" do
|
||||||
|
preset = %Station{input: "EM12", lat: 32.5, lon: -97.0}
|
||||||
|
changeset = Station.changeset(preset, %{position: 1})
|
||||||
|
|
||||||
|
assert changeset.valid?
|
||||||
|
assert get_change(changeset, :position) == 1
|
||||||
|
# lat/lon already set, no input change → no re-resolution
|
||||||
|
assert get_field(changeset, :lat) == 32.5
|
||||||
|
assert get_field(changeset, :lon) == -97.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts position field" do
|
||||||
|
changeset = Station.changeset(%Station{}, %{input: "32.9, -96.8", position: 3})
|
||||||
|
assert changeset.valid?
|
||||||
|
assert get_change(changeset, :position) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "default position is 0" do
|
||||||
|
assert %Station{}.position == 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "schema metadata" do
|
||||||
|
test "has expected fields" do
|
||||||
|
fields = Station.__schema__(:fields)
|
||||||
|
assert :callsign in fields
|
||||||
|
assert :grid in fields
|
||||||
|
assert :input in fields
|
||||||
|
assert :lat in fields
|
||||||
|
assert :lon in fields
|
||||||
|
assert :position in fields
|
||||||
|
assert :mission_id in fields
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -96,6 +96,61 @@ defmodule Microwaveprop.Weather.HrdpsClientTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "build_profile_from_extracted/1 — DEPR fallback" do
|
||||||
|
test "derives surface dewpoint from TMP - DEPR when DPT is missing" do
|
||||||
|
extracted = %{
|
||||||
|
"TMP:2 m above ground" => 290.0,
|
||||||
|
"DEPR:2 m above ground" => 5.0,
|
||||||
|
"PRES:surface" => 100_000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = HrdpsClient.build_profile_from_extracted(extracted)
|
||||||
|
|
||||||
|
assert_in_delta profile.surface_temp_c, 16.85, 0.01
|
||||||
|
# 290 - 5 = 285 K = 11.85 C
|
||||||
|
assert_in_delta profile.surface_dewpoint_c, 11.85, 0.01
|
||||||
|
end
|
||||||
|
|
||||||
|
test "uses TCDC entire-atmosphere when surface variant is missing" do
|
||||||
|
extracted = %{
|
||||||
|
"TMP:2 m above ground" => 280.0,
|
||||||
|
"DPT:2 m above ground" => 275.0,
|
||||||
|
"PRES:surface" => 100_000.0,
|
||||||
|
"TCDC:entire atmosphere" => 90.0
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = HrdpsClient.build_profile_from_extracted(extracted)
|
||||||
|
assert profile.cloud_cover_pct == 90.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns nil surface scalars when nothing surface-level is in the input" do
|
||||||
|
profile = HrdpsClient.build_profile_from_extracted(%{})
|
||||||
|
assert profile.surface_temp_c == nil
|
||||||
|
assert profile.surface_dewpoint_c == nil
|
||||||
|
assert profile.surface_pressure_mb == nil
|
||||||
|
assert profile.profile == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "skips a pressure level that is missing HGT" do
|
||||||
|
extracted = %{
|
||||||
|
"TMP:2 m above ground" => 290.0,
|
||||||
|
"DPT:2 m above ground" => 285.0,
|
||||||
|
"PRES:surface" => 100_000.0,
|
||||||
|
# Level 850 mb has TMP+DPT but no HGT — skipped.
|
||||||
|
"TMP:850 mb" => 275.0,
|
||||||
|
"DPT:850 mb" => 270.0,
|
||||||
|
# Level 700 mb is fully populated.
|
||||||
|
"TMP:700 mb" => 265.0,
|
||||||
|
"DPT:700 mb" => 260.0,
|
||||||
|
"HGT:700 mb" => 3000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
profile = HrdpsClient.build_profile_from_extracted(extracted)
|
||||||
|
assert length(profile.profile) == 1
|
||||||
|
assert hd(profile.profile)["pres"] == 700.0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "cycle_available?/1 (injected http_head)" do
|
describe "cycle_available?/1 (injected http_head)" do
|
||||||
setup do
|
setup do
|
||||||
prev = Application.get_env(:microwaveprop, :hrdps_http_head)
|
prev = Application.get_env(:microwaveprop, :hrdps_http_head)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,39 @@ defmodule Microwaveprop.Weather.NexradClientTest do
|
||||||
alias Microwaveprop.Weather.NexradCache
|
alias Microwaveprop.Weather.NexradCache
|
||||||
alias Microwaveprop.Weather.NexradClient
|
alias Microwaveprop.Weather.NexradClient
|
||||||
|
|
||||||
|
describe "fetch_rain_cells/4 with injected http_get" do
|
||||||
|
setup do
|
||||||
|
prev = Application.get_env(:microwaveprop, :nexrad_http_get)
|
||||||
|
NexradCache.clear()
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
if prev,
|
||||||
|
do: Application.put_env(:microwaveprop, :nexrad_http_get, prev),
|
||||||
|
else: Application.delete_env(:microwaveprop, :nexrad_http_get)
|
||||||
|
|
||||||
|
NexradCache.clear()
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "propagates an HTTP non-200 status from the IEM archive" do
|
||||||
|
Application.put_env(:microwaveprop, :nexrad_http_get, fn _url, _opts ->
|
||||||
|
{:ok, %{status: 404, body: ""}}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:error, "NEXRAD n0q HTTP 404"} = NexradClient.fetch_rain_cells(32.9, -97.0, 50.0, 25.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "propagates a transport error" do
|
||||||
|
Application.put_env(:microwaveprop, :nexrad_http_get, fn _url, _opts ->
|
||||||
|
{:error, :timeout}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert {:error, :timeout} = NexradClient.fetch_rain_cells(32.9, -97.0, 50.0, 25.0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe "round_to_5min/1" do
|
describe "round_to_5min/1" do
|
||||||
test "rounds down to nearest 5-minute boundary" do
|
test "rounds down to nearest 5-minute boundary" do
|
||||||
dt = ~U[2022-08-20 14:07:32Z]
|
dt = ~U[2022-08-20 14:07:32Z]
|
||||||
|
|
|
||||||
212
test/microwaveprop/weather/rebatch_asos_test.exs
Normal file
212
test/microwaveprop/weather/rebatch_asos_test.exs
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
defmodule Microwaveprop.Weather.RebatchAsosTest do
|
||||||
|
use Microwaveprop.DataCase, async: false
|
||||||
|
|
||||||
|
import ExUnit.CaptureIO
|
||||||
|
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
alias Microwaveprop.Weather.RebatchAsos
|
||||||
|
|
||||||
|
describe "run/1 — no pending jobs" do
|
||||||
|
test "returns zeros and prints a no-op message when no jobs exist" do
|
||||||
|
output =
|
||||||
|
capture_io(fn ->
|
||||||
|
assert summary = RebatchAsos.run()
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert_received %{found: 0, windows: 0, inserted: 0, cancelled: 0}
|
||||||
|
assert output =~ "No pending"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "to_day_jobs/1 — no pending jobs" do
|
||||||
|
test "returns zeros when no asos/asos_batch jobs exist" do
|
||||||
|
output =
|
||||||
|
capture_io(fn ->
|
||||||
|
assert summary = RebatchAsos.to_day_jobs()
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert_received %{source_jobs: 0, unique_day_keys: 0, inserted: 0, cancelled: 0}
|
||||||
|
assert output =~ "No pending"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "run/1 — dry-run with pending jobs" do
|
||||||
|
test "groups by (start_dt, end_dt) and reports without mutating" do
|
||||||
|
insert_asos_job!(%{
|
||||||
|
"fetch_type" => "asos",
|
||||||
|
"station_id" => 1,
|
||||||
|
"station_code" => "KDFW",
|
||||||
|
"start_dt" => "2026-04-18T00:00:00Z",
|
||||||
|
"end_dt" => "2026-04-18T04:00:00Z"
|
||||||
|
})
|
||||||
|
|
||||||
|
insert_asos_job!(%{
|
||||||
|
"fetch_type" => "asos",
|
||||||
|
"station_id" => 2,
|
||||||
|
"station_code" => "KDAL",
|
||||||
|
"start_dt" => "2026-04-18T00:00:00Z",
|
||||||
|
"end_dt" => "2026-04-18T04:00:00Z"
|
||||||
|
})
|
||||||
|
|
||||||
|
output =
|
||||||
|
capture_io(fn ->
|
||||||
|
summary = RebatchAsos.run(dry_run: true)
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert_received %{found: 2, windows: 1, inserted: 0, cancelled: 0}
|
||||||
|
assert output =~ "[dry-run]"
|
||||||
|
assert output =~ "2026-04-18T00:00:00Z"
|
||||||
|
# Original two jobs should still be available; nothing was cancelled.
|
||||||
|
assert pending_asos_count() == 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "to_day_jobs/1 — dry-run with pending asos jobs" do
|
||||||
|
test "expands one asos job into a per-date day-key plan" do
|
||||||
|
# A single 4-hour window inside one UTC day → 1 unique (station, date).
|
||||||
|
insert_asos_job!(%{
|
||||||
|
"fetch_type" => "asos",
|
||||||
|
"station_id" => 1,
|
||||||
|
"station_code" => "KDFW",
|
||||||
|
"start_dt" => "2026-04-18T00:00:00Z",
|
||||||
|
"end_dt" => "2026-04-18T04:00:00Z"
|
||||||
|
})
|
||||||
|
|
||||||
|
output =
|
||||||
|
capture_io(fn ->
|
||||||
|
summary = RebatchAsos.to_day_jobs(dry_run: true)
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert_received %{source_jobs: 1, unique_day_keys: 1, inserted: 0, cancelled: 0}
|
||||||
|
assert output =~ "[dry-run]"
|
||||||
|
assert output =~ "KDFW"
|
||||||
|
assert output =~ "2026-04-18"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "expands an asos_batch into one day-key per (station, date)" do
|
||||||
|
insert_asos_job!(%{
|
||||||
|
"fetch_type" => "asos_batch",
|
||||||
|
"station_ids" => [1, 2],
|
||||||
|
"station_codes" => ["KDFW", "KDAL"],
|
||||||
|
"start_dt" => "2026-04-18T00:00:00Z",
|
||||||
|
"end_dt" => "2026-04-18T04:00:00Z"
|
||||||
|
})
|
||||||
|
|
||||||
|
capture_io(fn ->
|
||||||
|
summary = RebatchAsos.to_day_jobs(dry_run: true)
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# One asos_batch with 2 stations × 1 date → 2 unique (station, date) keys.
|
||||||
|
assert_received %{source_jobs: 1, unique_day_keys: 2}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "asos jobs spanning two UTC dates produce two day-keys per station" do
|
||||||
|
insert_asos_job!(%{
|
||||||
|
"fetch_type" => "asos",
|
||||||
|
"station_id" => 1,
|
||||||
|
"station_code" => "KDFW",
|
||||||
|
"start_dt" => "2026-04-18T22:00:00Z",
|
||||||
|
"end_dt" => "2026-04-19T02:00:00Z"
|
||||||
|
})
|
||||||
|
|
||||||
|
capture_io(fn ->
|
||||||
|
summary = RebatchAsos.to_day_jobs(dry_run: true)
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# 1 station × 2 dates → 2 unique keys.
|
||||||
|
assert_received %{source_jobs: 1, unique_day_keys: 2}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "asos jobs missing window timestamps contribute zero day-keys" do
|
||||||
|
insert_asos_job!(%{
|
||||||
|
"fetch_type" => "asos",
|
||||||
|
"station_id" => 1,
|
||||||
|
"station_code" => "KDFW",
|
||||||
|
"start_dt" => nil,
|
||||||
|
"end_dt" => nil
|
||||||
|
})
|
||||||
|
|
||||||
|
capture_io(fn ->
|
||||||
|
summary = RebatchAsos.to_day_jobs(dry_run: true)
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
|
||||||
|
# Job is found but expand_tuples returns [] for nil window.
|
||||||
|
assert_received %{source_jobs: 1, unique_day_keys: 0}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "run/1 — apply (non-dry-run)" do
|
||||||
|
test "inserts an asos_batch job and cancels the source rows" do
|
||||||
|
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||||
|
insert_asos_job!(%{
|
||||||
|
"fetch_type" => "asos",
|
||||||
|
"station_id" => 1,
|
||||||
|
"station_code" => "KDFW",
|
||||||
|
"start_dt" => "2026-04-18T00:00:00Z",
|
||||||
|
"end_dt" => "2026-04-18T04:00:00Z"
|
||||||
|
})
|
||||||
|
|
||||||
|
capture_io(fn ->
|
||||||
|
summary = RebatchAsos.run()
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert_received %{found: 1, windows: 1, inserted: inserted, cancelled: cancelled}
|
||||||
|
assert inserted >= 1
|
||||||
|
assert cancelled >= 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "to_day_jobs/1 — apply (non-dry-run)" do
|
||||||
|
test "inserts asos_day jobs and cancels source rows" do
|
||||||
|
Oban.Testing.with_testing_mode(:manual, fn ->
|
||||||
|
insert_asos_job!(%{
|
||||||
|
"fetch_type" => "asos",
|
||||||
|
"station_id" => 1,
|
||||||
|
"station_code" => "KDFW",
|
||||||
|
"start_dt" => "2026-04-18T00:00:00Z",
|
||||||
|
"end_dt" => "2026-04-18T04:00:00Z"
|
||||||
|
})
|
||||||
|
|
||||||
|
capture_io(fn ->
|
||||||
|
summary = RebatchAsos.to_day_jobs()
|
||||||
|
send(self(), summary)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert_received %{source_jobs: 1, unique_day_keys: 1, inserted: inserted, cancelled: cancelled}
|
||||||
|
assert inserted >= 1
|
||||||
|
assert cancelled >= 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp insert_asos_job!(args) do
|
||||||
|
{:ok, job} =
|
||||||
|
args
|
||||||
|
|> Oban.Job.new(worker: "Microwaveprop.Workers.WeatherFetchWorker", queue: "weather")
|
||||||
|
|> Repo.insert()
|
||||||
|
|
||||||
|
job
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pending_asos_count do
|
||||||
|
Repo.aggregate(
|
||||||
|
Ecto.Query.from(j in Oban.Job,
|
||||||
|
where:
|
||||||
|
j.worker == "Microwaveprop.Workers.WeatherFetchWorker" and
|
||||||
|
j.state in ["available", "scheduled", "retryable"]
|
||||||
|
),
|
||||||
|
:count
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
defmodule Microwaveprop.Workers.RoverMissionReconcileWorkerTest do
|
||||||
|
use Microwaveprop.DataCase, async: false
|
||||||
|
use Oban.Testing, repo: Microwaveprop.Repo
|
||||||
|
|
||||||
|
alias Microwaveprop.Workers.RoverMissionReconcileWorker
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "no-ops when the mission does not exist" do
|
||||||
|
# Idempotency: a mission that was deleted between enqueue and run
|
||||||
|
# must return :ok, not crash, so Oban doesn't retry forever.
|
||||||
|
assert :ok =
|
||||||
|
perform_job(RoverMissionReconcileWorker, %{
|
||||||
|
mission_id: Ecto.UUID.generate()
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
defmodule Microwaveprop.Workers.RoverPathProfileWorkerTest do
|
||||||
|
use Microwaveprop.DataCase, async: false
|
||||||
|
use Oban.Testing, repo: Microwaveprop.Repo
|
||||||
|
|
||||||
|
alias Microwaveprop.Workers.RoverPathProfileWorker
|
||||||
|
|
||||||
|
describe "perform/1" do
|
||||||
|
test "no-ops when matching path does not exist" do
|
||||||
|
args = %{
|
||||||
|
"mission_id" => Ecto.UUID.generate(),
|
||||||
|
"rover_location_id" => Ecto.UUID.generate(),
|
||||||
|
"station_id" => Ecto.UUID.generate(),
|
||||||
|
"band_mhz" => 10_000
|
||||||
|
}
|
||||||
|
|
||||||
|
assert :ok = perform_job(RoverPathProfileWorker, args)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "drops job when args are missing the integer band_mhz" do
|
||||||
|
args = %{
|
||||||
|
"mission_id" => Ecto.UUID.generate(),
|
||||||
|
"rover_location_id" => Ecto.UUID.generate(),
|
||||||
|
"station_id" => Ecto.UUID.generate()
|
||||||
|
}
|
||||||
|
|
||||||
|
assert :ok = perform_job(RoverPathProfileWorker, args)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "drops job when band_mhz is a string" do
|
||||||
|
# `load_path/1` only matches integer band_mhz; non-integer falls
|
||||||
|
# through to the warning + nil branch.
|
||||||
|
args = %{
|
||||||
|
"mission_id" => Ecto.UUID.generate(),
|
||||||
|
"rover_location_id" => Ecto.UUID.generate(),
|
||||||
|
"station_id" => Ecto.UUID.generate(),
|
||||||
|
"band_mhz" => "10000"
|
||||||
|
}
|
||||||
|
|
||||||
|
assert :ok = perform_job(RoverPathProfileWorker, args)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "backoff/1" do
|
||||||
|
test "exponential backoff bounded at 3600s" do
|
||||||
|
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 1}) == 60
|
||||||
|
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 2}) == 120
|
||||||
|
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 3}) == 240
|
||||||
|
# Caps at 3600 once exponential growth exceeds it (2^6 * 60 = 3840 > 3600).
|
||||||
|
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 7}) == 3600
|
||||||
|
assert RoverPathProfileWorker.backoff(%Oban.Job{attempt: 20}) == 3600
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
|
||||||
|
|
||||||
import Microwaveprop.AccountsFixtures
|
import Microwaveprop.AccountsFixtures
|
||||||
|
|
||||||
|
alias Microwaveprop.Qrz.Client
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
alias Microwaveprop.Workers.UserHomeQthLookupWorker
|
alias Microwaveprop.Workers.UserHomeQthLookupWorker
|
||||||
|
|
||||||
|
|
@ -28,6 +29,45 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
|
||||||
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: Ecto.UUID.generate()})
|
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: Ecto.UUID.generate()})
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "logs and returns :ok when CallsignClient returns an error" do
|
||||||
|
user = user_fixture()
|
||||||
|
|
||||||
|
# Make QRZ return a 404 → CallsignClient.locate returns {:error, _}.
|
||||||
|
Req.Test.stub(Client, fn conn ->
|
||||||
|
Plug.Conn.send_resp(conn, 404, "Not Found")
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: user.id})
|
||||||
|
reloaded = Repo.reload(user)
|
||||||
|
assert is_nil(reloaded.home_grid)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "computes grid via Maidenhead when QRZ provides only lat/lon" do
|
||||||
|
user = user_fixture()
|
||||||
|
|
||||||
|
# No <grid> tag — falls through to Maidenhead.from_latlon/3.
|
||||||
|
Req.Test.stub(Client, fn conn ->
|
||||||
|
Plug.Conn.send_resp(conn, 200, """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<QRZDatabase>
|
||||||
|
<Session><Key>test-session</Key></Session>
|
||||||
|
<Callsign>
|
||||||
|
<call>#{user.callsign}</call>
|
||||||
|
<lat>33.10</lat>
|
||||||
|
<lon>-96.625</lon>
|
||||||
|
</Callsign>
|
||||||
|
</QRZDatabase>
|
||||||
|
""")
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: user.id})
|
||||||
|
|
||||||
|
reloaded = Repo.reload(user)
|
||||||
|
# Maidenhead-derived grid for ~33.10, -96.625 → EM13...
|
||||||
|
assert is_binary(reloaded.home_grid)
|
||||||
|
assert reloaded.home_grid =~ ~r/^EM13/
|
||||||
|
end
|
||||||
|
|
||||||
test "populates home QTH from a successful CallsignClient lookup" do
|
test "populates home QTH from a successful CallsignClient lookup" do
|
||||||
user = user_fixture()
|
user = user_fixture()
|
||||||
|
|
||||||
|
|
@ -35,7 +75,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
|
||||||
# The callsign-location pipeline reads either lat/lon or grid from
|
# The callsign-location pipeline reads either lat/lon or grid from
|
||||||
# the QRZ XML; we serve coords directly so the geocoder fallback
|
# the QRZ XML; we serve coords directly so the geocoder fallback
|
||||||
# isn't exercised.
|
# isn't exercised.
|
||||||
Req.Test.stub(Microwaveprop.Qrz.Client, fn conn ->
|
Req.Test.stub(Client, fn conn ->
|
||||||
Plug.Conn.send_resp(conn, 200, """
|
Plug.Conn.send_resp(conn, 200, """
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<QRZDatabase>
|
<QRZDatabase>
|
||||||
|
|
|
||||||
27
test/microwaveprop_web/live/live_stash_guard_test.exs
Normal file
27
test/microwaveprop_web/live/live_stash_guard_test.exs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
defmodule MicrowavepropWeb.LiveStashGuardTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MicrowavepropWeb.LiveStashGuard
|
||||||
|
alias Phoenix.LiveView.Socket
|
||||||
|
|
||||||
|
describe "stash/1" do
|
||||||
|
test "swallows ArgumentError when LiveStash hasn't been initialized" do
|
||||||
|
# An uninitialized socket has no `private.live_stash_adapter`, so
|
||||||
|
# `LiveStash.stash/1` raises ArgumentError. The guard catches it
|
||||||
|
# and returns the socket unchanged.
|
||||||
|
socket = %Socket{}
|
||||||
|
|
||||||
|
assert ^socket = LiveStashGuard.stash(socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns socket unchanged from a fresh struct (no adapter init)" do
|
||||||
|
socket = %Socket{assigns: %{count: 1}}
|
||||||
|
|
||||||
|
result = LiveStashGuard.stash(socket)
|
||||||
|
|
||||||
|
# Same socket back — preserves all assigns the LV had set up.
|
||||||
|
assert result == socket
|
||||||
|
assert result.assigns.count == 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue