defmodule MicrowavepropWeb.RoverPlanningLiveTest do use MicrowavepropWeb.ConnCase, async: true import Ecto.Query, only: [from: 2] 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 … 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(33.189, -96.452) 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 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 the cached stored-path detail page", %{conn: conn} do user = AccountsFixtures.user_fixture() mission = create_mission(user, "Click-through mission") {:ok, lv, _html} = live(conn, ~p"/rover-planning/#{mission.id}") [path | _] = Repo.all(Path) rendered = render(lv) # Each row navigates to /path?rover_path_id=UUID — PathLive # detects the param, deserializes the worker-cached compute # output, and skips the live recompute entirely. assert rendered =~ "phx-click" assert rendered =~ "/path?rover_path_id=#{path.id}" 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" assert html =~ "Add rover site" 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