Clicking the trash icon on a stationary station in :edit mode before any other phx-change had fired emptied the form's stations params, which combined with cast_assoc(on_replace: :delete) wiped EVERY existing station instead of just the one the user clicked. Fall back to rebuilding the params from the persisted mission's stations when the form's current params don't already carry them, then delete only the targeted index.
705 lines
24 KiB
Elixir
705 lines
24 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 "loss column renders the cached total baseline path loss", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Loss column mission")
|
||
|
||
[path | _] = Repo.all(Path)
|
||
|
||
{:ok, _} =
|
||
path
|
||
|> Ecto.Changeset.change(%{
|
||
status: :complete,
|
||
result: %{
|
||
"distance_km" => 12.0,
|
||
"verdict" => "CLEAR",
|
||
"free_space_loss_db" => 134.7,
|
||
"oxygen_loss_db" => 0.08,
|
||
"humidity_loss_db_baseline" => 0.18,
|
||
"total_baseline_loss_db" => 135.0
|
||
}
|
||
})
|
||
|> Repo.update()
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
assert html =~ "Loss"
|
||
# The TOTAL is what the user actually cares about at-a-glance —
|
||
# FSPL alone undersells humid-band paths. Show with one decimal +
|
||
# "dB" suffix.
|
||
assert html =~ "135.0 dB"
|
||
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 "stationary stations list shows uppercase callsign + grid, not bare lat/lon", %{conn: conn} do
|
||
import Ecto.Query, only: [from: 2]
|
||
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Stationary label mission")
|
||
|
||
# Backdate the station to mimic legacy data: callsign persisted in
|
||
# lowercase, no stored grid. The display should still render the
|
||
# callsign uppercase and synthesize a grid from lat/lon.
|
||
mission_id = mission.id
|
||
|
||
[s1] =
|
||
Repo.all(from s in Microwaveprop.RoverPlanning.Station, where: s.mission_id == ^mission_id)
|
||
|
||
{:ok, _} =
|
||
s1
|
||
|> Ecto.Changeset.change(%{callsign: "aa5c", grid: nil, lat: 33.1889, lon: -96.4517})
|
||
|> Repo.update()
|
||
|
||
{:ok, _lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
|
||
assert html =~ "AA5C"
|
||
# Bare lat/lon must not appear in the stationary-stations list.
|
||
refute html =~ "33.1889, -96.4517"
|
||
# A grid (derived from lat/lon when none is stored) IS shown.
|
||
assert html =~ Maidenhead.from_latlon(33.1889, -96.4517, 6)
|
||
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 "typing a new rover site surfaces an existing match by 6-char grid",
|
||
%{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Match-existing mission")
|
||
conn = log_in_user(conn, user)
|
||
|
||
# Existing site at a known grid; the user-typed coords below
|
||
# land in the same 6-char Maidenhead cell, so the form should
|
||
# surface a link to the existing site rather than letting the
|
||
# user duplicate it.
|
||
{:ok, existing} =
|
||
Rover.create_location(user, %{
|
||
lat: 32.5,
|
||
lon: -97.5,
|
||
status: :good,
|
||
notes: "Existing parking spot"
|
||
})
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
|
||
html =
|
||
lv
|
||
|> form("form[phx-submit='add_rover_site']", %{"input" => "32.5004, -97.4998"})
|
||
|> render_change()
|
||
|
||
# The 6-char grid for both points is identical, so a match link
|
||
# to the existing site appears with its grid label.
|
||
assert html =~ ~s(href="/rover-locations/#{existing.id})
|
||
grid6 = Maidenhead.from_latlon(32.5, -97.5, 6)
|
||
assert html =~ grid6
|
||
end
|
||
|
||
test "non-matching input shows no existing-site link", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "No-match mission")
|
||
conn = log_in_user(conn, user)
|
||
|
||
{:ok, _other} =
|
||
Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :good})
|
||
|
||
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
|
||
html =
|
||
lv
|
||
|> form("form[phx-submit='add_rover_site']", %{"input" => "45.0, -120.0"})
|
||
|> render_change()
|
||
|
||
refute html =~ "Already saved as"
|
||
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 "delete button lives inside the path-profile group heading, not above it",
|
||
%{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
mission = create_mission(user, "Per-group delete mission")
|
||
conn = log_in_user(conn, user)
|
||
|
||
[site] = Repo.all(Location)
|
||
|
||
{:ok, lv, html} = live(conn, ~p"/rover-planning/#{mission.id}")
|
||
|
||
# The standalone "Rover sites" list is gone — the heading now reads
|
||
# "Add rover site" (still the form). Each path-profile group
|
||
# heading carries its own per-location delete button. Asserting on
|
||
# the structural ancestor (`[data-rover-group]`) ensures the button
|
||
# is INSIDE the grouping, not in some legacy list above it.
|
||
refute html =~ "Rover sites</h3>"
|
||
assert html =~ "Add rover site</h3>"
|
||
|
||
assert has_element?(
|
||
lv,
|
||
"[data-rover-group] button[phx-click='delete_rover_site'][phx-value-id='#{site.id}']"
|
||
)
|
||
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 "edit-mode remove_station deletes ONLY the targeted station", %{conn: conn} do
|
||
user = AccountsFixtures.user_fixture()
|
||
_loc = AccountsFixtures.user_fixture()
|
||
|
||
mission =
|
||
create_mission(user, "Multi-station edit",
|
||
stations: %{
|
||
"0" => %{"input" => "EM12kp", "position" => 0},
|
||
"1" => %{"input" => "EM13ks", "position" => 1},
|
||
"2" => %{"input" => "EM14lr", "position" => 2}
|
||
}
|
||
)
|
||
|
||
conn = log_in_user(conn, user)
|
||
{:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}/edit")
|
||
|
||
# Click the trash icon on the second station (index 1) without
|
||
# any prior validate firing — this was the bug: an empty
|
||
# current_params + cast_assoc(on_replace: :delete) wiped every
|
||
# existing station instead of just the targeted index.
|
||
_html =
|
||
lv
|
||
|> element("button[phx-click='remove_station'][phx-value-index='1']")
|
||
|> render_click()
|
||
|
||
# Save and assert only stations 0 and 2 remain.
|
||
{:error, {:live_redirect, _}} =
|
||
lv
|
||
|> form("#mission-form")
|
||
|> render_submit()
|
||
|
||
reloaded = Repo.preload(Repo.get!(Microwaveprop.RoverPlanning.Mission, mission.id), :stations)
|
||
assert length(reloaded.stations) == 2
|
||
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",
|
||
bands_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",
|
||
bands_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
|