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
|
||||
use ExUnit.Case, async: true
|
||||
use Microwaveprop.DataCase, async: false
|
||||
use ExUnitProperties
|
||||
|
||||
alias Microwaveprop.Propagation.BandConfig
|
||||
|
|
@ -96,6 +96,42 @@ defmodule Microwaveprop.Propagation.PathComputeTest do
|
|||
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
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,26 @@ defmodule Microwaveprop.RoverPlanning.PathTest do
|
|||
|
||||
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
|
||||
test "valid attrs produce a valid changeset" do
|
||||
attrs = %{
|
||||
|
|
@ -69,6 +89,19 @@ defmodule Microwaveprop.RoverPlanning.PathTest do
|
|||
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
|
||||
attrs = %{
|
||||
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
|
||||
|
||||
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
|
||||
setup do
|
||||
prev = Application.get_env(:microwaveprop, :hrdps_http_head)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,39 @@ defmodule Microwaveprop.Weather.NexradClientTest do
|
|||
alias Microwaveprop.Weather.NexradCache
|
||||
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
|
||||
test "rounds down to nearest 5-minute boundary" do
|
||||
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
|
||||
|
||||
alias Microwaveprop.Qrz.Client
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.Workers.UserHomeQthLookupWorker
|
||||
|
||||
|
|
@ -28,6 +29,45 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
|
|||
assert :ok = perform_job(UserHomeQthLookupWorker, %{user_id: Ecto.UUID.generate()})
|
||||
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
|
||||
user = user_fixture()
|
||||
|
||||
|
|
@ -35,7 +75,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorkerTest do
|
|||
# The callsign-location pipeline reads either lat/lon or grid from
|
||||
# the QRZ XML; we serve coords directly so the geocoder fallback
|
||||
# isn't exercised.
|
||||
Req.Test.stub(Microwaveprop.Qrz.Client, fn conn ->
|
||||
Req.Test.stub(Client, fn conn ->
|
||||
Plug.Conn.send_resp(conn, 200, """
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<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