prop/test/microwaveprop_web/live/rover_planning_live_test.exs
Graham McIntire 8fd9759e4e
feat(rover-planning): add/remove rover sites inline on the show page
A new "Rover sites" section between Stationary stations and Path
profiles lists the candidate rover locations the mission scores
against, with:

- An add form that takes the same flexible input as station inputs
  (callsign, Maidenhead grid, or `lat,lon`); on submit it creates a
  global :good rover-location and re-runs the path matrix.
- A per-row trash button visible to the location's owner / admins
  that deletes the location and re-runs the matrix.

`RoverPlanning.candidate_rover_locations/1` is now public so the show
page can list exactly what the worker enqueues against. Add/remove
both call `replace_mission_paths/1` so the matrix stays consistent
with the rover-site set.

Anonymous visitors see a sign-in prompt instead of the form.
2026-05-03 12:47:07 -05:00

340 lines
12 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 MicrowavepropWeb.RoverPlanningLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover
alias Microwaveprop.Rover.Location
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.Terrain.ElevationClient
setup do
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_mission(user, name, opts \\ []) do
locations = Keyword.get(opts, :locations, [%{lat: 32.0, lon: -97.0, status: :good}])
stations = Keyword.get(opts, :stations, %{"0" => %{"input" => "EM12kp", "position" => 0}})
Enum.each(locations, fn attrs -> {:ok, _} = Rover.create_location(user, attrs) end)
{:ok, mission} =
RoverPlanning.create_mission(user, %{
"name" => name,
"band_mhz" => 10_000,
"only_known_good" => true,
"rover_height_ft" => 8.0,
"station_height_ft" => 30.0,
"stations" => stations
})
mission
end
describe "index" do
test "anonymous visitor sees the table and a sign-in prompt", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
assert html =~ "Rover Planning"
assert html =~ "Sign in"
end
test "logged-in user sees the New mission button", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user)
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
assert html =~ "New mission"
end
test "table shows existing missions and links to the show page", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Visible mission")
{:ok, _lv, html} = live(conn, ~p"/rover-planning")
assert html =~ "Visible mission"
assert html =~ ~s(href="/rover-planning/#{mission.id}")
end
end
describe "show" do
test "renders mission details + path table", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Detail mission")
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
assert html =~ "Detail mission"
assert html =~ "Stationary stations"
assert html =~ "Path profiles"
end
test "group heading uses max-precision Maidenhead grid (10 chars)", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Precision mission")
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
# Maidenhead.from_latlon(32.0, -97.0, 10) → "EM12VX00AA" (or similar
# 10-char grid). The group heading must NOT truncate at 6 chars.
grid_10 = Maidenhead.from_latlon(32.0, -97.0, 10)
assert String.length(grid_10) == 10
assert html =~ grid_10
end
test "station label uses derived grid when only lat/lon are stored", %{conn: conn} do
user = AccountsFixtures.user_fixture()
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :good})
# User enters raw coords (no callsign or grid lookup) → station ends
# up with lat/lon set but callsign/grid blank.
{:ok, mission} =
RoverPlanning.create_mission(user, %{
"name" => "Coord-only station mission",
"band_mhz" => 10_000,
"only_known_good" => true,
"rover_height_ft" => 8.0,
"station_height_ft" => 30.0,
"stations" => %{"0" => %{"input" => "33.189,-96.452", "position" => 0}}
})
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
derived = Maidenhead.from_latlon(33.189, -96.452, 6)
assert html =~ derived
# The path-profiles "Station" cell must render the derived grid,
# not the bare lat/lon. Match the exact <td>…</td> shape so we
# don't trip on the Stationary-stations list at the top of the
# page (which legitimately shows lat/lon as a secondary line).
refute html =~ ~s(<td class="font-mono text-xs">33.189, -96.452</td>)
end
test "station label shows callsign + grid together when both are known", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Callsign-grid mission")
[path | _] = Path |> Repo.all() |> Repo.preload(:station)
{:ok, _} = path.station |> Ecto.Changeset.change(%{callsign: "AA5C", grid: "EM13se"}) |> Repo.update()
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
# The path table should display "AA5C · EM13se" so the operator can
# see both at a glance — not just one or the other and not lat/lon.
assert html =~ "AA5C · EM13se"
end
test "path rows link to /path with mission params prefilled", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Click-through mission")
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
# Each path row carries a phx-click that navigates to /path with
# the mission's band + heights and the rover/station endpoints
# filled in. Assert the click handler is present on the <tr>.
rendered = render(lv)
assert rendered =~ "phx-click"
assert rendered =~ "/path?"
assert rendered =~ "band=10000"
assert rendered =~ "src_height_ft=8.0"
assert rendered =~ "dst_height_ft=30.0"
end
test "groups paths by rover location", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission =
create_mission(user, "Grouped mission",
locations: [
%{lat: 32.0, lon: -97.0, status: :good, notes: "Spot Alpha"},
%{lat: 33.0, lon: -98.0, status: :good, notes: "Spot Bravo"}
],
stations: %{
"0" => %{"input" => "EM12kp", "position" => 0},
"1" => %{"input" => "EM13qc", "position" => 1}
}
)
{:ok, lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
# One group container per rover location; both lat/lon labels appear
# as group headings (not inline in cells like the old flat table).
assert has_element?(lv, "[data-rover-group]")
assert html =~ "32.0"
assert html =~ "33.0"
# 2 locations × 2 stations = 4 path rows split into 2 groups.
group_count =
lv
|> render()
|> then(&Regex.scan(~r/data-rover-group/, &1))
|> length()
assert group_count == 2
end
test "renders integer path-result values without crashing", %{conn: conn} do
# JSONB round-trips bare zeros as integers (e.g. `0` not `0.0`), so the
# show page MUST tolerate non-float numerics in `result`. Previously
# `Float.round(value, n)` crashed with FunctionClauseError when value
# came back as integer 0.
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Integer result mission")
[path | _] = Repo.all(Path)
{:ok, _} =
path
|> Ecto.Changeset.change(%{
status: :complete,
result: %{
"distance_km" => 0,
"min_clearance_m" => 0,
"diffraction_db" => 0,
"verdict" => "clear"
}
})
|> Repo.update()
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
assert html =~ "Integer result mission"
assert html =~ "Clear"
end
test "owner can add a rover site from the show page", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Add-site mission")
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
html =
lv
|> form("form[phx-submit='add_rover_site']", %{"input" => "33.5, -97.5"})
|> render_submit()
# New site appears in the rover-sites list, and a flash confirms.
assert html =~ "Rover site added"
assert html =~ Maidenhead.from_latlon(33.5, -97.5, 10)
assert Repo.aggregate(Location, :count) == 2
end
test "owner can remove a rover site from the show page", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Remove-site mission")
conn = log_in_user(conn, user)
[site] = Repo.all(Location)
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
html =
lv
|> element("button[phx-click='delete_rover_site'][phx-value-id='#{site.id}']")
|> render_click()
assert html =~ "Rover site removed"
assert Repo.aggregate(Location, :count) == 0
end
test "anonymous visitor sees a sign-in prompt instead of the add form", %{conn: conn} do
user = AccountsFixtures.user_fixture()
mission = create_mission(user, "Anon-view mission")
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
refute has_element?(lv, "form[phx-submit='add_rover_site']")
assert has_element?(lv, "a", "Sign in")
end
test "redirects when mission missing", %{conn: conn} do
missing = Ecto.UUID.generate()
assert {:error, {:live_redirect, %{to: "/rover-planning"}}} =
live(conn, ~p"/rover-planning/#{missing}")
end
end
describe "form" do
test "anonymous user gets redirected to /rover-planning", %{conn: conn} do
assert {:error, {:live_redirect, %{to: "/rover-planning"}}} =
live(conn, ~p"/rover-planning/new")
end
test "clicking 'Add station' does not show 'can't be blank' on the new row", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-planning/new")
html =
lv
|> element("button[phx-click='add_station']")
|> render_click()
# The new (untouched) station must not render a validation error until
# the user actually interacts with its input.
refute html =~ "can&#39;t be blank"
end
test "typing into the form does not flip the only_known_good checkbox", %{conn: conn} do
user = AccountsFixtures.user_fixture()
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/rover-planning/new")
html =
lv
|> form("#mission-form",
mission: %{
name: "Some name",
band_mhz: "10000",
rover_height_ft: "8.0",
station_height_ft: "30.0",
only_known_good: "true",
stations: %{"0" => %{input: ""}}
}
)
|> render_change()
# The default-checked box must STILL be checked after a phx-change.
# Hidden "false" + checkbox "true" with hidden first = checkbox wins.
assert html =~
~s(name="mission[only_known_good]" value="true" checked)
end
test "logged-in user can create a mission with a callsign-input station",
%{conn: conn} do
user = AccountsFixtures.user_fixture()
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :good})
conn = log_in_user(conn, user)
{:ok, lv, html} = live(conn, ~p"/rover-planning/new")
assert html =~ "Only check against known good locations"
assert html =~ ~s(checked)
{:error, {:live_redirect, %{to: redirect_to}}} =
lv
|> form("#mission-form",
mission: %{
name: "From form",
band_mhz: "10000",
only_known_good: "true",
rover_height_ft: "8.0",
station_height_ft: "30.0",
stations: %{
"0" => %{input: "EM12kp"}
}
}
)
|> render_submit()
assert redirect_to =~ ~r{^/rover-planning/[0-9a-f-]+$}
end
end
end