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.
539 lines
18 KiB
Elixir
539 lines
18 KiB
Elixir
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 cell renders callsign + grid as separate lines", %{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}")
|
||
|
||
# Callsign and grid both appear, on separate lines (callsign primary,
|
||
# grid as a muted secondary). The legacy one-liner "AA5C · EM13se"
|
||
# no longer appears anywhere.
|
||
assert html =~ "AA5C"
|
||
assert html =~ "EM13se"
|
||
refute html =~ "AA5C · EM13se"
|
||
end
|
||
|
||
test "verdict column renders the badge for completed paths", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Verdict mission")
|
||
|
||
[path | _] = Repo.all(Path)
|
||
|
||
{:ok, _} =
|
||
path
|
||
|> Ecto.Changeset.change(%{
|
||
status: :complete,
|
||
result: %{
|
||
"distance_km" => 12.0,
|
||
"verdict" => "CLEAR",
|
||
"min_clearance_m" => 50.0,
|
||
"diffraction_db" => 0.0
|
||
}
|
||
})
|
||
|> Repo.update()
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
assert html =~ "Clear"
|
||
end
|
||
|
||
test "verdict column maps BLOCKED + FRESNEL_PARTIAL to badges", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
|
||
mission =
|
||
create_mission(user, "Blocked mission",
|
||
locations: [
|
||
%{lat: 32.0, lon: -97.0, status: :good},
|
||
%{lat: 33.0, lon: -96.0, status: :good}
|
||
]
|
||
)
|
||
|
||
[p1, p2] = Repo.all(Path)
|
||
|
||
{:ok, _} =
|
||
p1
|
||
|> Ecto.Changeset.change(%{
|
||
status: :complete,
|
||
result: %{"distance_km" => 8.0, "verdict" => "BLOCKED"}
|
||
})
|
||
|> Repo.update()
|
||
|
||
{:ok, _} =
|
||
p2
|
||
|> Ecto.Changeset.change(%{
|
||
status: :complete,
|
||
result: %{"distance_km" => 9.0, "verdict" => "FRESNEL_PARTIAL"}
|
||
})
|
||
|> Repo.update()
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
assert html =~ "Blocked"
|
||
assert html =~ "Fresnel"
|
||
end
|
||
|
||
test "propagation column renders score badge for completed paths", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Score column mission")
|
||
|
||
[path | _] = Repo.all(Path)
|
||
|
||
{:ok, _} =
|
||
path
|
||
|> Ecto.Changeset.change(%{
|
||
status: :complete,
|
||
result: %{
|
||
"distance_km" => 12.0,
|
||
"verdict" => "CLEAR",
|
||
"propagation_score" => 78
|
||
}
|
||
})
|
||
|> Repo.update()
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
assert html =~ "Propagation"
|
||
assert html =~ "78"
|
||
end
|
||
|
||
test "propagation column renders dash when score is missing", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "No-score mission")
|
||
|
||
[path | _] = Repo.all(Path)
|
||
|
||
{:ok, _} =
|
||
path
|
||
|> Ecto.Changeset.change(%{
|
||
status: :complete,
|
||
result: %{"distance_km" => 12.0, "verdict" => "CLEAR", "propagation_score" => nil}
|
||
})
|
||
|> Repo.update()
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
assert html =~ "Propagation"
|
||
end
|
||
|
||
test "rover-location group heading links to /rover-locations/:id", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Linked heading mission")
|
||
|
||
[path | _] = Repo.all(Path)
|
||
loc_id = path.rover_location_id
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
# The heading should be a clickable link to the rover-location's
|
||
# detail page so the user can quickly inspect / edit the spot.
|
||
assert html =~ ~s(href="/rover-locations/#{loc_id}")
|
||
end
|
||
|
||
test "path table omits min clearance + diffraction columns", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Trim columns mission")
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
# Header columns relevant to a planning summary: Station / Status /
|
||
# Distance / Verdict. Min clearance + diffraction are detail-level
|
||
# values better viewed on the Path Calculator itself.
|
||
refute html =~ "Min clearance"
|
||
refute html =~ "Diffraction"
|
||
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 "add_rover_site rejects whitespace-only input with a flash error",
|
||
%{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Empty-input mission")
|
||
conn = log_in_user(conn, user)
|
||
|
||
{:ok, lv, _} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
|
||
html =
|
||
lv
|
||
|> form("form[phx-submit='add_rover_site']", %{"input" => " "})
|
||
|> render_submit()
|
||
|
||
# ` ` trims to "" → LocationResolver returns :empty, which the
|
||
# handler maps to this flash. No new location was created.
|
||
assert html =~ "Enter a callsign, grid, or lat,lon"
|
||
assert Repo.aggregate(Location, :count) == 1
|
||
end
|
||
|
||
test "delete_rover_site shows error when the user does not own the site",
|
||
%{conn: conn} do
|
||
owner = AccountsFixtures.user_fixture()
|
||
mission = create_mission(owner, "Foreign-site mission")
|
||
|
||
[site] = Repo.all(Location)
|
||
|
||
visitor = AccountsFixtures.user_fixture()
|
||
conn = log_in_user(conn, visitor)
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
|
||
# Visitor doesn't own the site, so the trash button is hidden;
|
||
# but we exercise the server-side guard directly via render_hook
|
||
# to confirm the not_found branch.
|
||
html = render_hook(lv, "delete_rover_site", %{"id" => site.id})
|
||
|
||
assert html =~ "You can only delete rover sites you created"
|
||
assert Repo.aggregate(Location, :count) == 1
|
||
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'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 "remove_station drops the targeted station from the form", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
conn = log_in_user(conn, user)
|
||
{:ok, lv, _html} = live(conn, ~p"/rover-planning/new")
|
||
|
||
# The new form seeds one station; click Add to get a second.
|
||
_ = lv |> element("button[phx-click='add_station']") |> render_click()
|
||
assert render(lv) =~ ~s(phx-value-index="1")
|
||
|
||
html =
|
||
lv
|
||
|> element("button[phx-click='remove_station'][phx-value-index='1']")
|
||
|> render_click()
|
||
|
||
refute html =~ ~s(phx-value-index="1")
|
||
assert html =~ ~s(phx-value-index="0")
|
||
end
|
||
|
||
test "add_station appends a new row on the very first click", %{conn: conn} do
|
||
# Regression: before seeding initial params with one station, the
|
||
# first add_station click was a no-op (cast_assoc replaced the
|
||
# default Station struct with the new params row instead of
|
||
# appending). Both indices must render after a single click.
|
||
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()
|
||
|
||
assert html =~ ~s(phx-value-index="0")
|
||
assert html =~ ~s(phx-value-index="1")
|
||
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
|