Mission now carries bands_mhz ({:array, :integer}) — operator picks
one or more bands as multi-checkboxes. enqueue_paths_for builds the
cross product (rover x station x band) and persists each tuple as its
own Path row keyed by (mission_id, rover_location_id, station_id,
band_mhz). The path-profile worker reads band_mhz from the path
itself (legacy single-band jobs without band_mhz in args still resolve
to their unique row).
replace_mission_paths/1 is now a thin alias for reconcile_mission_paths/1
which diffs desired vs actual: stale tuples (old band that the user
unchecked, station they removed, rover-site they deleted) get dropped,
new tuples become :pending and enqueue, surviving :complete rows are
left in place — no more wholesale destruction of already-computed
paths on every edit.
The show table gains a Band column, and band_label() in the mission
summary becomes bands_label() (joins the list with commas).
266 lines
10 KiB
Elixir
266 lines
10 KiB
Elixir
defmodule Microwaveprop.RoverPlanningTest do
|
||
use Microwaveprop.DataCase, async: true
|
||
|
||
alias Microwaveprop.AccountsFixtures
|
||
alias Microwaveprop.Repo
|
||
alias Microwaveprop.Rover
|
||
alias Microwaveprop.RoverPlanning
|
||
alias Microwaveprop.RoverPlanning.Mission
|
||
alias Microwaveprop.Terrain.ElevationClient
|
||
|
||
setup do
|
||
# Oban runs inline in test, so RoverPathProfileWorker fires immediately
|
||
# when the context enqueues jobs. Stub elevation lookups so the worker
|
||
# can complete without hitting the real API.
|
||
Req.Test.stub(ElevationClient, fn conn ->
|
||
params = Plug.Conn.fetch_query_params(conn).query_params
|
||
lat_count = params["latitude"] |> String.split(",") |> length()
|
||
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
||
end)
|
||
|
||
:ok
|
||
end
|
||
|
||
defp create_rover_location(attrs) do
|
||
user = AccountsFixtures.user_fixture()
|
||
|
||
{:ok, loc} =
|
||
Rover.create_location(
|
||
user,
|
||
Map.merge(%{lat: 32.5, lon: -97.5, status: :good}, attrs)
|
||
)
|
||
|
||
loc
|
||
end
|
||
|
||
defp valid_attrs(extra \\ %{}) do
|
||
Map.merge(
|
||
%{
|
||
"name" => "Test mission",
|
||
"band_mhz" => 10_000,
|
||
"only_known_good" => true,
|
||
"rover_height_ft" => 8.0,
|
||
"station_height_ft" => 30.0,
|
||
"stations" => %{
|
||
"0" => %{"input" => "EM12kp", "position" => 0}
|
||
}
|
||
},
|
||
extra
|
||
)
|
||
end
|
||
|
||
describe "create_mission/2" do
|
||
test "inserts a mission with stations and enqueues a path per (rover × station) pair" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_ideal_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
_off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad})
|
||
|
||
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||
assert mission.user_id == user.id
|
||
assert [station] = mission.stations
|
||
assert station.grid == "EM12KP"
|
||
assert is_float(station.lat)
|
||
assert is_float(station.lon)
|
||
|
||
paths = RoverPlanning.list_paths(mission)
|
||
# only_known_good=true → only the :good location pairs with the station.
|
||
# Oban runs inline in test so the worker has already completed.
|
||
assert length(paths) == 1
|
||
assert hd(paths).status in [:complete, :failed]
|
||
end
|
||
|
||
test "completed path caches the link-budget loss components" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
|
||
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||
[path] = RoverPlanning.list_paths(mission)
|
||
assert path.status == :complete
|
||
|
||
# The result map MUST carry the cached loss-budget so the show
|
||
# page (and any future caller) can render link math without
|
||
# re-running the computation. Values come from the band's
|
||
# absorption coefficients × distance + free-space path loss + the
|
||
# already-cached terrain diffraction. Stored as floats.
|
||
assert is_float(path.result["free_space_loss_db"])
|
||
assert is_float(path.result["oxygen_loss_db"])
|
||
assert is_float(path.result["humidity_loss_db_baseline"])
|
||
assert is_float(path.result["total_baseline_loss_db"])
|
||
# Free-space loss for a ~140 km, 10 GHz path is ~155 dB; sanity
|
||
# check it's in the right order of magnitude (not zero, not 1000).
|
||
assert path.result["free_space_loss_db"] > 100
|
||
assert path.result["free_space_loss_db"] < 200
|
||
end
|
||
|
||
test "completed path stores a propagation_score key (may be nil without scores file)" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
|
||
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||
[path] = RoverPlanning.list_paths(mission)
|
||
# The worker is the gate: status only flips to :complete after the
|
||
# path-calculator pipeline (terrain + propagation lookup) has run.
|
||
# The score itself is nil in the test env (no .prop file on disk),
|
||
# but the key MUST be present so the show-page can render "—"
|
||
# without a key-existence check at every cell.
|
||
assert path.status == :complete
|
||
assert Map.has_key?(path.result, "propagation_score")
|
||
end
|
||
|
||
test "backfill_paths/0 re-runs failed paths across all missions" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
|
||
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||
[path] = RoverPlanning.list_paths(mission)
|
||
assert path.status == :complete
|
||
|
||
# Simulate a path that was stuck in :failed (e.g. transient
|
||
# elevation API error during the original computation).
|
||
{:ok, _} =
|
||
path
|
||
|> Ecto.Changeset.change(%{status: :failed, error: "transient", result: nil})
|
||
|> Repo.update()
|
||
|
||
assert :ok = RoverPlanning.backfill_paths()
|
||
|
||
# Oban inline → backfill enqueues a job, the worker reruns
|
||
# immediately, and the path flips back to :complete.
|
||
[reloaded] = RoverPlanning.list_paths(mission)
|
||
assert reloaded.status == :complete
|
||
assert is_map(reloaded.result)
|
||
end
|
||
|
||
test "backfill_paths/0 inserts missing rows for under-populated missions" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
|
||
assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||
# Simulate a half-broken mission: paths were never persisted (e.g.
|
||
# the original create_mission Multi succeeded but the Oban insert
|
||
# was lost due to a crash). Backfill must repopulate the matrix.
|
||
Repo.delete_all(Ecto.Query.from(p in RoverPlanning.Path, where: p.mission_id == ^mission.id))
|
||
assert RoverPlanning.list_paths(mission) == []
|
||
|
||
assert :ok = RoverPlanning.backfill_paths()
|
||
|
||
assert [path] = RoverPlanning.list_paths(mission)
|
||
assert path.status == :complete
|
||
end
|
||
|
||
test "multi-band mission inserts one path per (rover, station, band)" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
|
||
attrs =
|
||
valid_attrs(%{
|
||
"bands_mhz" => [10_000, 24_000, 47_000],
|
||
# Form may still include band_mhz (legacy primary band); normalize
|
||
# should overwrite it from bands_mhz in any case.
|
||
"band_mhz" => 10_000
|
||
})
|
||
|
||
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
|
||
assert mission.bands_mhz == [10_000, 24_000, 47_000]
|
||
|
||
paths = RoverPlanning.list_paths(mission)
|
||
assert length(paths) == 3
|
||
assert Enum.sort(Enum.map(paths, & &1.band_mhz)) == [10_000, 24_000, 47_000]
|
||
end
|
||
|
||
test "reconcile drops paths whose band is no longer requested" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
|
||
attrs = valid_attrs(%{"bands_mhz" => [10_000, 24_000]})
|
||
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
|
||
assert length(RoverPlanning.list_paths(mission)) == 2
|
||
|
||
# User narrows the mission to a single band — reconcile should
|
||
# remove the now-unused 24 GHz row, keep the 10 GHz row.
|
||
mission =
|
||
mission
|
||
|> Mission.changeset(%{"bands_mhz" => [10_000]})
|
||
|> Repo.update!()
|
||
|> Repo.preload([:user, :stations], force: true)
|
||
|
||
:ok = RoverPlanning.replace_mission_paths(mission)
|
||
|
||
paths = RoverPlanning.list_paths(mission)
|
||
assert length(paths) == 1
|
||
assert hd(paths).band_mhz == 10_000
|
||
end
|
||
|
||
test "with only_known_good=false, all rover-locations get a path entry" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_ideal = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
_off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad})
|
||
|
||
attrs = valid_attrs(%{"only_known_good" => false})
|
||
assert {:ok, mission} = RoverPlanning.create_mission(user, attrs)
|
||
assert length(RoverPlanning.list_paths(mission)) == 2
|
||
end
|
||
|
||
test "missing stations returns a changeset error" do
|
||
user = AccountsFixtures.user_fixture()
|
||
attrs = Map.delete(valid_attrs(), "stations")
|
||
|
||
assert {:error, %Ecto.Changeset{} = cs} = RoverPlanning.create_mission(user, attrs)
|
||
refute cs.valid?
|
||
assert {_, _} = cs.errors[:stations] || hd(cs.errors)
|
||
end
|
||
end
|
||
|
||
describe "update_mission/3" do
|
||
test "owner can update a mission and paths get rebuilt" do
|
||
user = AccountsFixtures.user_fixture()
|
||
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
{:ok, mission} = RoverPlanning.create_mission(user, valid_attrs())
|
||
|
||
[original] = RoverPlanning.list_paths(mission)
|
||
|
||
attrs = valid_attrs(%{"name" => "Updated"})
|
||
assert {:ok, updated} = RoverPlanning.update_mission(user, mission.id, attrs)
|
||
assert updated.name == "Updated"
|
||
|
||
# Old path was deleted by replace, fresh row inserted with new id.
|
||
[fresh] = RoverPlanning.list_paths(updated)
|
||
refute fresh.id == original.id
|
||
end
|
||
|
||
test "non-owner update is denied" do
|
||
owner = AccountsFixtures.user_fixture()
|
||
other = AccountsFixtures.user_fixture()
|
||
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||
|
||
assert {:error, :not_found} =
|
||
RoverPlanning.update_mission(other, mission.id, valid_attrs())
|
||
end
|
||
|
||
test "admin can update any mission" do
|
||
owner = AccountsFixtures.user_fixture()
|
||
admin = AccountsFixtures.user_fixture()
|
||
{:ok, admin} = Microwaveprop.Accounts.admin_update_user(admin, %{is_admin: true})
|
||
_ = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good})
|
||
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||
|
||
assert {:ok, updated} =
|
||
RoverPlanning.update_mission(admin, mission.id, valid_attrs(%{"name" => "By admin"}))
|
||
|
||
assert updated.name == "By admin"
|
||
end
|
||
end
|
||
|
||
describe "delete_mission/2" do
|
||
test "owner can delete; non-owner cannot" do
|
||
owner = AccountsFixtures.user_fixture()
|
||
other = AccountsFixtures.user_fixture()
|
||
{:ok, mission} = RoverPlanning.create_mission(owner, valid_attrs())
|
||
|
||
assert {:error, :not_found} = RoverPlanning.delete_mission(other, mission.id)
|
||
assert {:ok, _} = RoverPlanning.delete_mission(owner, mission.id)
|
||
refute Repo.get(Mission, mission.id)
|
||
end
|
||
end
|
||
end
|