prop/test/microwaveprop/rover_planning_test.exs
Graham McIntire 210e941db8
feat(rover-planning): propagation score column + linked location heading
The path-profile worker now also looks up the propagation grid score for
the path midpoint at the mission's band before flipping status to
:complete, so the row never appears 'done' until both terrain and
propagation prediction have run. The show table renders that score as a
red/yellow/green badge in a new Propagation column.

Each rover-location group heading is now a link to /rover-locations/:id
so the user can jump straight to the spot's detail page from the
mission view.
2026-05-03 13:35:54 -05:00

159 lines
5.9 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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 "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