feat(rover-planning): /rover-planning page with path-profile worker

Adds a new globally-scoped, owner-mutable rover-mission tracker:
- /rover-planning paginated table (LiveTable) with View/Edit/Delete
- /rover-planning/new + /:id/edit form: name, band, antenna heights,
  notes, "only check against known good locations" toggle (default on),
  and a dynamic list of stationary stations entered as callsigns,
  Maidenhead grids, or lat,lon pairs (Station changeset geocodes via
  LocationResolver, lat/lon stays editable after add)
- /rover-planning/:id show page renders the station list, scope, and a
  matrix of computed path profiles (distance, min clearance, diffraction,
  verdict) populated as the worker completes each pairing

After save, RoverPlanning enqueues one RoverPathProfileWorker job per
(rover-location × station) pairing. The worker mirrors PathLive's
synchronous compute (ElevationClient + TerrainAnalysis at the mission's
band + heights) and stores the result on the matching path row.
PubSub broadcast on completion lets the show page live-refresh.

Admins can edit/delete any mission; owners can edit their own.
This commit is contained in:
Graham McIntire 2026-05-03 11:04:11 -05:00
parent a3fff198c9
commit d1a5442afb
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
13 changed files with 1682 additions and 3 deletions

View file

@ -0,0 +1,197 @@
defmodule Microwaveprop.RoverPlanning do
@moduledoc """
Context for the `/rover-planning` feature: planned rover missions
with stationary endpoints and per-rover-location terrain path
profiles. Mutations are scoped to a user (or admin); reads are
global.
After a successful `create_mission/2` or
`replace_mission_paths/1`, the context enqueues one
`RoverPathProfileWorker` job per (rover-location, station) pair
so the show page can render results as they land.
"""
import Ecto.Query
alias Ecto.Multi
alias Microwaveprop.Accounts.User
alias Microwaveprop.Repo
alias Microwaveprop.Rover.Location
alias Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.RoverPlanning.Station
alias Microwaveprop.Workers.RoverPathProfileWorker
@spec list_missions() :: [Mission.t()]
def list_missions do
Mission
|> order_by([m], desc: m.inserted_at)
|> preload([:user, :stations])
|> Repo.all()
end
@spec get_mission(Ecto.UUID.t()) :: Mission.t() | nil
def get_mission(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, uuid} ->
Mission
|> Repo.get(uuid)
|> Repo.preload([:user, stations: from(s in Station, order_by: s.position)])
:error ->
nil
end
end
@spec get_mission_with_paths(Ecto.UUID.t()) :: Mission.t() | nil
def get_mission_with_paths(id) do
case get_mission(id) do
nil -> nil
mission -> Repo.preload(mission, paths: [:rover_location, :station])
end
end
@spec create_mission(User.t(), map()) ::
{:ok, Mission.t()} | {:error, Ecto.Changeset.t()}
def create_mission(%User{} = user, attrs) do
changeset = Mission.changeset(%Mission{user_id: user.id}, attrs)
case Repo.insert(changeset) do
{:ok, mission} ->
mission = Repo.preload(mission, [:user, :stations])
:ok = enqueue_paths_for(mission)
{:ok, mission}
{:error, _} = error ->
error
end
end
@spec update_mission(User.t(), Ecto.UUID.t(), map()) ::
{:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_mission(%User{} = user, id, attrs) do
case fetch_owned(user, id) do
{:ok, mission} ->
mission
|> Repo.preload(stations: from(s in Station, order_by: s.position))
|> Mission.changeset(attrs)
|> Repo.update()
|> case do
{:ok, updated} ->
updated = Repo.preload(updated, [:user, :stations], force: true)
:ok = replace_mission_paths(updated)
{:ok, updated}
other ->
other
end
{:error, :not_found} ->
{:error, :not_found}
end
end
@spec delete_mission(User.t(), Ecto.UUID.t()) ::
{:ok, Mission.t()} | {:error, :not_found}
def delete_mission(%User{} = user, id) do
case fetch_owned(user, id) do
{:ok, mission} -> Repo.delete(mission)
{:error, :not_found} -> {:error, :not_found}
end
end
@spec list_paths(Mission.t()) :: [Path.t()]
def list_paths(%Mission{id: mission_id}) do
Repo.all(
from(p in Path,
where: p.mission_id == ^mission_id,
preload: [:rover_location, :station],
order_by: [asc: p.station_id, asc: p.rover_location_id]
)
)
end
@doc """
Wipes the existing path matrix for a mission and re-enqueues all
pairings. Called after a mission edit when the station list or
`only_known_good` toggle could have shifted the matrix.
"""
@spec replace_mission_paths(Mission.t()) :: :ok
def replace_mission_paths(%Mission{} = mission) do
Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id))
enqueue_paths_for(mission)
end
defp enqueue_paths_for(%Mission{} = mission) do
rovers = candidate_rover_locations(mission)
stations = mission.stations || []
pairs =
for rover <- rovers, station <- stations do
%{rover: rover, station: station}
end
case pairs do
[] -> :ok
_ -> insert_pending_paths_and_jobs(mission, pairs)
end
end
defp candidate_rover_locations(%Mission{only_known_good: true}) do
Repo.all(from(l in Location, where: l.status == :ideal))
end
defp candidate_rover_locations(%Mission{only_known_good: false}) do
Repo.all(Location)
end
defp insert_pending_paths_and_jobs(mission, pairs) do
{:ok, _} =
pairs
|> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi ->
attrs = %{
mission_id: mission.id,
rover_location_id: r.id,
station_id: s.id,
status: :pending
}
Multi.insert(multi, {:path, r.id, s.id}, Path.changeset(%Path{}, attrs),
on_conflict: :nothing,
conflict_target: [:mission_id, :rover_location_id, :station_id]
)
end)
|> Repo.transaction()
enqueue_jobs(mission, pairs)
:ok
end
defp enqueue_jobs(mission, pairs) do
for %{rover: r, station: s} <- pairs do
%{
"mission_id" => mission.id,
"rover_location_id" => r.id,
"station_id" => s.id
}
|> RoverPathProfileWorker.new()
|> Oban.insert()
end
:ok
end
defp fetch_owned(%User{is_admin: true}, id) do
case get_mission(id) do
nil -> {:error, :not_found}
mission -> {:ok, mission}
end
end
defp fetch_owned(%User{id: user_id}, id) do
case get_mission(id) do
%Mission{user_id: ^user_id} = mission -> {:ok, mission}
_ -> {:error, :not_found}
end
end
end

View file

@ -0,0 +1,71 @@
defmodule Microwaveprop.RoverPlanning.Mission do
@moduledoc """
A planned rover mission. Owned by a user but globally visible. Holds
the band + height parameters that drive path-profile computation, the
list of stationary endpoints (`stations`), and the matrix of computed
paths from each rover-location to each station (`paths`).
`only_known_good` toggles whether the rover endpoint set is the
`:ideal` rover-locations (default) or every rover-location.
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.RoverPlanning.Station
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "rover_missions" do
field :name, :string
field :band_mhz, :integer, default: 10_000
field :only_known_good, :boolean, default: true
field :rover_height_ft, :float, default: 8.0
field :station_height_ft, :float, default: 30.0
field :notes, :string
belongs_to :user, User
has_many :stations, Station, foreign_key: :mission_id, on_replace: :delete
has_many :paths, Path, foreign_key: :mission_id
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@castable_fields [
:name,
:band_mhz,
:only_known_good,
:rover_height_ft,
:station_height_ft,
:notes
]
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(mission, attrs) do
mission
|> cast(attrs, @castable_fields)
|> cast_assoc(:stations, with: &Station.changeset/2, required: true)
|> validate_required([:name, :band_mhz])
|> validate_length(:name, min: 1, max: 100)
|> validate_number(:band_mhz, greater_than: 0)
|> validate_number(:rover_height_ft, greater_than_or_equal_to: 0.0)
|> validate_number(:station_height_ft, greater_than_or_equal_to: 0.0)
|> validate_length(:notes, max: 4000)
|> validate_at_least_one_station()
end
defp validate_at_least_one_station(changeset) do
case fetch_field(changeset, :stations) do
{_source, list} when is_list(list) and list != [] ->
kept = Enum.reject(list, fn s -> match?(%Ecto.Changeset{action: :replace}, s) end)
if kept == [], do: add_error(changeset, :stations, "must include at least one station"), else: changeset
_ ->
add_error(changeset, :stations, "must include at least one station")
end
end
end

View file

@ -0,0 +1,56 @@
defmodule Microwaveprop.RoverPlanning.Path do
@moduledoc """
A computed (or pending) path-profile result for one rover-location
station pairing inside a mission. `result` holds the same shape
produced by `MicrowavepropWeb.PathLive`'s synchronous compute, so
the show page can render it identically.
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Rover.Location
alias Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.RoverPlanning.Station
@statuses [:pending, :computing, :complete, :failed]
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "rover_mission_paths" do
field :status, Ecto.Enum, values: @statuses, default: :pending
field :result, :map
field :error, :string
field :computed_at, :utc_datetime
belongs_to :mission, Mission
belongs_to :rover_location, Location
belongs_to :station, Station
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec statuses() :: [atom()]
def statuses, do: @statuses
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(path, attrs) do
path
|> cast(attrs, [
:status,
:result,
:error,
:computed_at,
:mission_id,
:rover_location_id,
:station_id
])
|> validate_required([:mission_id, :rover_location_id, :station_id, :status])
|> validate_inclusion(:status, @statuses)
|> unique_constraint([:mission_id, :rover_location_id, :station_id],
name: :rover_mission_paths_unique
)
end
end

View file

@ -0,0 +1,86 @@
defmodule Microwaveprop.RoverPlanning.Station do
@moduledoc """
A stationary endpoint for a rover mission. The user provides one of:
callsign, Maidenhead grid, or `lat, lon`; the changeset normalizes
the input into all three when possible (callsigns geocode via
`MicrowavepropWeb.LocationResolver`).
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.RoverPlanning.Mission
alias MicrowavepropWeb.LocationResolver
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "rover_mission_stations" do
field :callsign, :string
field :grid, :string
field :input, :string
field :lat, :float
field :lon, :float
field :position, :integer, default: 0
belongs_to :mission, Mission
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(station, attrs) do
station
|> cast(attrs, [:callsign, :grid, :input, :lat, :lon, :position])
|> validate_required([:input])
|> resolve_input()
|> validate_required([:lat, :lon])
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
end
# When `input` looks like a callsign or grid, geocode it and populate
# the matching field. Skips resolution when the user already provided
# explicit lat/lon — they may want a free-form coordinate that doesn't
# round-trip through callsign lookup.
defp resolve_input(changeset) do
cond do
not changeset.valid? ->
changeset
get_field(changeset, :lat) && get_field(changeset, :lon) ->
changeset
input = get_field(changeset, :input) ->
apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
true ->
changeset
end
end
defp apply_resolution(changeset, {:ok, %{kind: :callsign} = res}) do
changeset
|> put_change(:callsign, res.label)
|> put_change(:grid, Map.get(res, :grid))
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:ok, %{kind: :grid} = res}) do
changeset
|> put_change(:grid, res.label)
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:ok, %{kind: :coordinates} = res}) do
changeset
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:error, msg}), do: add_error(changeset, :input, msg)
defp apply_resolution(changeset, :empty), do: add_error(changeset, :input, "is required")
end

View file

@ -0,0 +1,177 @@
defmodule Microwaveprop.Workers.RoverPathProfileWorker do
@moduledoc """
Computes a terrain path profile for one rover-location station
pairing inside a `RoverPlanning.Mission`. Mirrors the synchronous
`MicrowavepropWeb.PathLive` compute pipeline (elevation profile +
ITU-R P.526 analysis at the mission's band + heights) and stores
the result as JSON on the matching `RoverPlanning.Path`.
The HRRR-along-path step from `/path` is intentionally skipped here:
it depends on real-time profile grids that aren't worth pinning to a
stored mission result. The rendered show page can re-fetch live
weather on demand if needed.
"""
use Oban.Worker,
queue: :terrain,
max_attempts: 5,
unique: [
period: 300,
keys: [:mission_id, :rover_location_id, :station_id],
states: [:available, :scheduled, :executing, :retryable]
]
import Ecto.Query
alias Microwaveprop.Geo
alias Microwaveprop.Repo
alias Microwaveprop.RoverPlanning.Path
alias Microwaveprop.Terrain.ElevationClient
alias Microwaveprop.Terrain.TerrainAnalysis
require Logger
@impl Oban.Worker
def backoff(%Oban.Job{attempt: attempt}) do
min(60 * Integer.pow(2, attempt - 1), 3600)
end
@impl Oban.Worker
def perform(%Oban.Job{
args: %{"mission_id" => mission_id, "rover_location_id" => rover_location_id, "station_id" => station_id}
}) do
case load_path(mission_id, rover_location_id, station_id) do
nil ->
:ok
%Path{status: :complete} ->
:ok
%Path{} = path ->
run(path)
end
end
defp load_path(mission_id, rover_location_id, station_id) do
Path
|> where([p], p.mission_id == ^mission_id)
|> where([p], p.rover_location_id == ^rover_location_id)
|> where([p], p.station_id == ^station_id)
|> preload([:rover_location, :station, :mission])
|> Repo.one()
end
defp run(%Path{rover_location: rover, station: station, mission: mission} = path) do
mark_status(path, :computing)
rover_height_m = (mission.rover_height_ft || 8.0) * 0.3048
station_height_m = (mission.station_height_ft || 30.0) * 0.3048
freq_ghz = mission.band_mhz / 1000
dist_km = Geo.haversine_km(rover.lat, rover.lon, station.lat, station.lon)
bearing = Geo.bearing_deg(rover.lat, rover.lon, station.lat, station.lon)
case ElevationClient.fetch_elevation_profile(
rover.lat,
rover.lon,
station.lat,
station.lon,
64,
download: true
) do
{:ok, profile} ->
analysis =
TerrainAnalysis.analyse(profile, dist_km, freq_ghz,
ant_ht_a: rover_height_m,
ant_ht_b: station_height_m
)
store_complete(path, profile, analysis, dist_km, bearing)
{:error, reason} ->
store_failed(path, "elevation profile failed: #{inspect(reason)}")
end
rescue
e ->
Logger.error("RoverPathProfileWorker crashed for path=#{path.id}: #{Exception.message(e)}")
store_failed(path, "exception: #{Exception.message(e)}")
reraise e, __STACKTRACE__
end
defp store_complete(path, profile, analysis, dist_km, bearing) do
points =
Enum.map(profile, fn p ->
%{
"lat" => p.lat,
"lon" => p.lon,
"d" => p.d,
"elev" => p.elev,
"dist_km" => p.dist_km
}
end)
result = %{
"distance_km" => dist_km,
"bearing_deg" => bearing,
"max_elevation_m" => analysis.max_elevation_m,
"min_clearance_m" => analysis.min_clearance_m,
"diffraction_db" => analysis.diffraction_db,
"fresnel_hit_count" => analysis.fresnel_hit_count,
"obstructed_count" => analysis.obstructed_count,
"verdict" => to_string(analysis.verdict),
"sample_count" => length(profile),
"path_points" => points
}
path
|> Path.changeset(%{
status: :complete,
result: result,
error: nil,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.update()
|> case do
{:ok, updated} ->
broadcast(updated)
:ok
{:error, cs} ->
{:error, "store update failed: #{inspect(cs.errors)}"}
end
end
defp store_failed(path, message) do
path
|> Path.changeset(%{
status: :failed,
error: message,
computed_at: DateTime.truncate(DateTime.utc_now(), :second)
})
|> Repo.update()
|> case do
{:ok, updated} ->
broadcast(updated)
{:error, message}
{:error, cs} ->
{:error, "store update failed: #{inspect(cs.errors)}"}
end
end
defp mark_status(path, status) do
path
|> Path.changeset(%{status: status})
|> Repo.update()
|> case do
{:ok, _} -> :ok
_ -> :ok
end
end
defp broadcast(%Path{mission_id: mission_id} = path) do
Phoenix.PubSub.broadcast(
Microwaveprop.PubSub,
"rover_planning:#{mission_id}",
{:rover_path_updated, path.id}
)
end
end

View file

@ -0,0 +1,203 @@
defmodule MicrowavepropWeb.RoverPlanningLive do
@moduledoc """
`/rover-planning` paginated table of planned rover missions.
Globally visible, owner-mutable; admins can edit/delete any mission.
"""
use MicrowavepropWeb, :live_view
use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning
def table_options do
%{sorting: %{default_sort: [inserted_at: :desc]}}
end
def fields do
[
id: %{label: "ID", hidden: true},
user_id: %{label: "Owner", hidden: true},
name: %{label: "Mission", sortable: true, searchable: true, renderer: &name_cell/2},
band_mhz: %{label: "Band", sortable: true, renderer: &band_cell/1},
only_known_good: %{label: "Scope", sortable: true, renderer: &scope_cell/1},
inserted_at: %{label: "Created", sortable: true, renderer: &format_ts/1}
]
end
def filters, do: []
@impl true
def mount(_params, _session, socket) do
{:ok,
assign(socket,
page_title: "Rover Planning"
)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
case current_user(socket) do
%User{} = user ->
case RoverPlanning.delete_mission(user, id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Mission deleted.")
|> reload()}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
end
_ ->
{:noreply, put_flash(socket, :error, "Sign in required.")}
end
end
defp reload(socket) do
options = socket.assigns.options
data_provider = socket.assigns[:data_provider]
table_options = get_merged_table_options()
{resources, updated_options} = fetch_resources(options, data_provider)
socket
|> assign_to_socket(resources, table_options)
|> assign(:options, updated_options)
end
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns)
defp current_user(assigns) when is_map(assigns), do: scope_user(assigns)
defp scope_user(assigns) do
case assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp can_modify?(scope, %{user_id: user_id}) do
case scope do
%{user: %User{is_admin: true}} -> true
%{user: %User{id: ^user_id}} when not is_nil(user_id) -> true
_ -> false
end
end
defp can_modify?(_, _), do: false
defp actions_for(scope) do
[
buttons: fn %{record: record} ->
row_actions(record, scope)
end
]
end
defp row_actions(record, scope) do
assigns = %{record: record, can_modify?: can_modify?(scope, record)}
~H"""
<div class="flex gap-2">
<.link
navigate={~p"/rover-planning/#{@record.id}"}
class="btn btn-xs btn-ghost"
>
View
</.link>
<.link
:if={@can_modify?}
navigate={~p"/rover-planning/#{@record.id}/edit"}
class="btn btn-xs btn-ghost"
>
Edit
</.link>
<button
:if={@can_modify?}
type="button"
phx-click="delete"
phx-value-id={@record.id}
data-confirm="Delete this mission?"
class="btn btn-xs btn-ghost text-error"
>
Delete
</button>
</div>
"""
end
defp name_cell(value, %{id: id}) do
assigns = %{id: id, name: value || "(unnamed)"}
~H"""
<.link navigate={~p"/rover-planning/#{@id}"} class="font-semibold hover:underline">
{@name}
</.link>
"""
end
defp band_cell(nil), do: ""
defp band_cell(mhz) when is_integer(mhz) do
if mhz >= 1000 do
ghz = mhz / 1000
label = if ghz == trunc(ghz), do: "#{trunc(ghz)}", else: Float.to_string(Float.round(ghz, 1))
"#{label} GHz"
else
"#{mhz} MHz"
end
end
defp scope_cell(true) do
assigns = %{}
~H|<span class="badge badge-success badge-sm">Known good only</span>|
end
defp scope_cell(false) do
assigns = %{}
~H|<span class="badge badge-info badge-sm">All locations</span>|
end
defp scope_cell(_), do: ""
defp format_ts(nil), do: ""
defp format_ts(%DateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
defp format_ts(%NaiveDateTime{} = dt), do: Calendar.strftime(dt, "%Y-%m-%d")
defp format_ts(other), do: to_string(other)
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
Rover Planning
<:subtitle>
Plan rover missions: enter your stationary stations, pick a band, and
we compute terrain path profiles from every known-good rover location.
</:subtitle>
<:actions>
<.link
:if={current_user(assigns)}
navigate={~p"/rover-planning/new"}
class="btn btn-primary"
>
<.icon name="hero-plus" class="w-5 h-5" /> New mission
</.link>
</:actions>
</.header>
<p :if={is_nil(current_user(assigns))} class="alert alert-info text-sm mb-4">
<a href={~p"/users/log-in"} class="link link-primary">Sign in</a> to plan a mission.
</p>
<.live_table
fields={fields()}
filters={filters()}
options={@options}
streams={@streams}
actions={actions_for(@current_scope)}
/>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,299 @@
defmodule MicrowavepropWeb.RoverPlanningLive.Form do
@moduledoc "New / edit form for `/rover-planning` missions."
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts.User
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
alias Microwaveprop.RoverPlanning.Station
@band_options [
{"902 MHz", 902},
{"1.3 GHz", 1296},
{"2.3 GHz", 2304},
{"3.4 GHz", 3400},
{"5.7 GHz", 5760},
{"10 GHz", 10_000},
{"24 GHz", 24_000},
{"47 GHz", 47_000}
]
@impl true
def mount(params, _session, socket) do
case current_user(socket) do
nil ->
{:ok,
socket
|> put_flash(:error, "Sign in to plan a mission.")
|> push_navigate(to: ~p"/rover-planning")}
%User{} = user ->
{:ok,
socket
|> assign(
page_title: "Rover Mission",
band_options: @band_options,
current_user: user
)
|> apply_action(socket.assigns.live_action, params)}
end
end
defp apply_action(socket, :new, _params) do
mission = %Mission{stations: [%Station{position: 0}]}
cs = Mission.changeset(mission, %{})
assign(socket,
mission: mission,
form: to_form(cs),
mode: :new
)
end
defp apply_action(socket, :edit, %{"id" => id}) do
user = socket.assigns.current_user
case RoverPlanning.get_mission(id) do
nil ->
socket
|> put_flash(:error, "Mission not found.")
|> push_navigate(to: ~p"/rover-planning")
mission ->
if can_modify?(user, mission) do
cs = Mission.changeset(mission, %{})
assign(socket,
mission: mission,
form: to_form(cs),
mode: :edit
)
else
socket
|> put_flash(:error, "You can only edit your own missions.")
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")
end
end
end
@impl true
def handle_event("validate", %{"mission" => params}, socket) do
cs =
socket.assigns.mission
|> Mission.changeset(params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
def handle_event("add_station", _params, socket) do
params = current_params(socket)
stations = Map.get(params, "stations", %{})
next_index = next_station_index(stations)
stations =
Map.put(stations, Integer.to_string(next_index), %{
"input" => "",
"position" => Integer.to_string(next_index)
})
cs =
socket.assigns.mission
|> Mission.changeset(Map.put(params, "stations", stations))
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
def handle_event("remove_station", %{"index" => index}, socket) do
params = current_params(socket)
stations = Map.get(params, "stations", %{})
stations = Map.delete(stations, index)
cs =
socket.assigns.mission
|> Mission.changeset(Map.put(params, "stations", stations))
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(cs))}
end
def handle_event("save", %{"mission" => params}, socket) do
user = socket.assigns.current_user
result =
case socket.assigns.mode do
:new -> RoverPlanning.create_mission(user, params)
:edit -> RoverPlanning.update_mission(user, socket.assigns.mission.id, params)
end
case result do
{:ok, mission} ->
{:noreply,
socket
|> put_flash(:info, "Mission saved — computing path profiles.")
|> push_navigate(to: ~p"/rover-planning/#{mission.id}")}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "Mission not found.")}
{:error, %Ecto.Changeset{} = cs} ->
{:noreply, assign(socket, form: to_form(cs))}
end
end
defp current_params(socket) do
case socket.assigns.form.source do
%Ecto.Changeset{params: params} when is_map(params) -> params
_ -> %{}
end
end
defp next_station_index(stations) when map_size(stations) == 0, do: 0
defp next_station_index(stations) do
stations
|> Map.keys()
|> Enum.map(fn k ->
case Integer.parse(to_string(k)) do
{n, _} -> n
:error -> -1
end
end)
|> Enum.max()
|> Kernel.+(1)
end
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
case assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp can_modify?(%User{is_admin: true}, _), do: true
defp can_modify?(%User{id: id}, %Mission{user_id: id}) when not is_nil(id), do: true
defp can_modify?(_, _), do: false
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-3xl">
<.header>
{if @mode == :edit, do: "Edit mission", else: "New rover mission"}
<:subtitle>
Stationary stations can be callsigns, Maidenhead grids, or <code>lat, lon</code> pairs.
</:subtitle>
</.header>
<.form
for={@form}
id="mission-form"
phx-change="validate"
phx-submit="save"
class="space-y-4"
>
<.input field={@form[:name]} type="text" label="Mission name" placeholder="DFW August rove" />
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<.input
field={@form[:band_mhz]}
type="select"
label="Band"
options={@band_options}
/>
<.input
field={@form[:rover_height_ft]}
type="number"
step="any"
label="Rover antenna (ft)"
/>
<.input
field={@form[:station_height_ft]}
type="number"
step="any"
label="Station antenna (ft)"
/>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="mission[only_known_good]"
value="true"
checked={Phoenix.HTML.Form.normalize_value("checkbox", @form[:only_known_good].value)}
class="checkbox checkbox-primary"
/>
<input type="hidden" name="mission[only_known_good]" value="false" />
<span>
Only check against known good locations
<span class="text-xs opacity-70 block">
When checked, paths are computed only against rover-locations marked Ideal.
</span>
</span>
</label>
<div>
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Stationary stations</h3>
<button
type="button"
phx-click="add_station"
class="btn btn-xs btn-ghost"
>
<.icon name="hero-plus" class="w-4 h-4" /> Add station
</button>
</div>
<.inputs_for :let={s} field={@form[:stations]}>
<div class="card bg-base-200 p-3 mb-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<.input
field={s[:input]}
type="text"
label="Callsign / grid / lat,lon"
placeholder="W5LUA or EM13qc or 32.91, -97.06"
/>
<div class="flex items-end gap-2">
<div class="flex-1">
<.input field={s[:lat]} type="number" step="any" label="Lat" />
</div>
<div class="flex-1">
<.input field={s[:lon]} type="number" step="any" label="Lon" />
</div>
<button
type="button"
phx-click="remove_station"
phx-value-index={s.index}
class="btn btn-ghost btn-sm text-error mb-2"
title="Remove station"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</div>
<p :if={s[:callsign].value} class="text-xs opacity-70 mt-1 px-1">
Resolved callsign: {s[:callsign].value}
<span :if={s[:grid].value}>· grid {s[:grid].value}</span>
</p>
</div>
</.inputs_for>
<p :if={@form[:stations].errors != []} class="text-error text-sm">
{Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))}
</p>
</div>
<.input field={@form[:notes]} type="textarea" label="Notes" rows="3" />
<div class="flex gap-2">
<button type="submit" class="btn btn-primary">
{if @mode == :edit, do: "Save changes", else: "Create mission"}
</button>
<.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel</.link>
</div>
</.form>
</Layouts.app>
"""
end
end

View file

@ -0,0 +1,264 @@
defmodule MicrowavepropWeb.RoverPlanningLive.Show do
@moduledoc """
`/rover-planning/:id` mission detail. Renders the planned stations
and the matrix of computed (or pending) terrain path profiles from
every candidate rover-location.
"""
use MicrowavepropWeb, :live_view
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
@impl true
def mount(%{"id" => id}, _session, socket) do
case RoverPlanning.get_mission_with_paths(id) do
nil ->
{:ok,
socket
|> put_flash(:error, "Mission not found.")
|> push_navigate(to: ~p"/rover-planning")}
%Mission{} = mission ->
if connected?(socket) do
Phoenix.PubSub.subscribe(Microwaveprop.PubSub, "rover_planning:#{mission.id}")
end
{:ok,
assign(socket,
page_title: "Mission · #{mission.name}",
mission: mission,
paths: RoverPlanning.list_paths(mission)
)}
end
end
@impl true
def handle_info({:rover_path_updated, _path_id}, socket) do
{:noreply, assign(socket, paths: RoverPlanning.list_paths(socket.assigns.mission))}
end
@impl true
def handle_event("delete", _params, socket) do
case current_user(socket) do
%User{} = user ->
case RoverPlanning.delete_mission(user, socket.assigns.mission.id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Mission deleted.")
|> push_navigate(to: ~p"/rover-planning")}
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, "You can only delete your own missions.")}
end
_ ->
{:noreply, put_flash(socket, :error, "Sign in required.")}
end
end
defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}) do
case assigns[:current_scope] do
%{user: %User{} = user} -> user
_ -> nil
end
end
defp can_modify?(%{current_scope: %{user: %User{is_admin: true}}}, _), do: true
defp can_modify?(%{current_scope: %{user: %User{id: id}}}, %Mission{user_id: id}) when not is_nil(id), do: true
defp can_modify?(_, _), do: false
defp band_label(mhz) when is_integer(mhz) and mhz >= 1000 do
ghz = mhz / 1000
if ghz == trunc(ghz), do: "#{trunc(ghz)} GHz", else: "#{Float.round(ghz, 1)} GHz"
end
defp band_label(mhz), do: "#{mhz} MHz"
defp status_badge(:complete) do
assigns = %{}
~H|<span class="badge badge-success badge-sm">Complete</span>|
end
defp status_badge(:computing) do
assigns = %{}
~H|<span class="badge badge-info badge-sm">Computing</span>|
end
defp status_badge(:failed) do
assigns = %{}
~H|<span class="badge badge-error badge-sm">Failed</span>|
end
defp status_badge(_) do
assigns = %{}
~H|<span class="badge badge-ghost badge-sm">Pending</span>|
end
defp verdict_badge("clear") do
assigns = %{}
~H|<span class="badge badge-success badge-xs">Clear</span>|
end
defp verdict_badge("blocked") do
assigns = %{}
~H|<span class="badge badge-error badge-xs">Blocked</span>|
end
defp verdict_badge("marginal") do
assigns = %{}
~H|<span class="badge badge-warning badge-xs">Marginal</span>|
end
defp verdict_badge(_), do: ""
defp progress_summary(paths) do
total = length(paths)
complete = Enum.count(paths, &(&1.status == :complete))
pending = Enum.count(paths, &(&1.status in [:pending, :computing]))
failed = Enum.count(paths, &(&1.status == :failed))
%{total: total, complete: complete, pending: pending, failed: failed}
end
defp format_distance(km) when is_number(km), do: "#{Float.round(km, 1)} km / #{Float.round(km * 0.621371, 1)} mi"
defp format_distance(_), do: ""
defp format_db(nil), do: ""
defp format_db(value) when is_number(value), do: "#{Float.round(value, 1)} dB"
defp rover_label(%{rover_location: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon) do
grid = Maidenhead.from_latlon(lat, lon, 6)
"#{grid} (#{Float.round(lat, 4)}, #{Float.round(lon, 4)})"
end
defp rover_label(_), do: ""
defp station_label(%{station: %{callsign: c}}) when is_binary(c) and c != "", do: c
defp station_label(%{station: %{grid: g}}) when is_binary(g) and g != "", do: g
defp station_label(%{station: %{lat: lat, lon: lon}}) when is_number(lat) and is_number(lon),
do: "#{Float.round(lat, 3)}, #{Float.round(lon, 3)}"
defp station_label(_), do: ""
@impl true
def render(assigns) do
summary = progress_summary(assigns.paths)
assigns = assign(assigns, :summary, summary)
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} max_width="max-w-6xl">
<.header>
{@mission.name}
<:subtitle>
{band_label(@mission.band_mhz)} · {length(@mission.stations)} station(s) ·
rover {@mission.rover_height_ft}ft / station {@mission.station_height_ft}ft
</:subtitle>
<:actions>
<.link navigate={~p"/rover-planning"} class="btn btn-ghost btn-sm">
<.icon name="hero-arrow-left" class="w-4 h-4" /> All missions
</.link>
<.link
:if={can_modify?(assigns, @mission)}
navigate={~p"/rover-planning/#{@mission.id}/edit"}
class="btn btn-ghost btn-sm"
>
<.icon name="hero-pencil-square" class="w-4 h-4" /> Edit
</.link>
<button
:if={can_modify?(assigns, @mission)}
type="button"
phx-click="delete"
data-confirm="Delete this mission?"
class="btn btn-ghost btn-sm text-error"
>
<.icon name="hero-trash" class="w-4 h-4" /> Delete
</button>
</:actions>
</.header>
<div class="card bg-base-100 border border-base-300 p-4 mb-4">
<h3 class="font-semibold mb-2">Stationary stations</h3>
<ul class="text-sm space-y-1">
<li :for={station <- @mission.stations} class="flex flex-wrap gap-2">
<span class="font-mono">{station.callsign || station.grid || station.input}</span>
<span :if={station.grid} class="text-base-content/60">grid {station.grid}</span>
<span class="text-base-content/60 font-mono">
{Float.round(station.lat, 4)}, {Float.round(station.lon, 4)}
</span>
</li>
</ul>
<p :if={@mission.notes && @mission.notes != ""} class="mt-3 text-sm whitespace-pre-line">
{@mission.notes}
</p>
<p class="text-xs text-base-content/60 mt-3">
Scope: {if @mission.only_known_good,
do: "Known good locations only (status = ideal)",
else: "All rover locations"} · Owner: {(@mission.user && @mission.user.callsign) || ""}
</p>
</div>
<div class="card bg-base-100 border border-base-300 p-4">
<div class="flex items-center justify-between mb-3 gap-2 flex-wrap">
<h3 class="font-semibold">Path profiles</h3>
<div class="text-xs text-base-content/70">
{@summary.complete}/{@summary.total} complete
<span :if={@summary.pending > 0}>· {@summary.pending} pending</span>
<span :if={@summary.failed > 0} class="text-error">
· {@summary.failed} failed
</span>
</div>
</div>
<div :if={@paths == []} class="text-sm text-base-content/60 py-4 text-center">
No paths yet they'll appear here as the workers run.
</div>
<div :if={@paths != []} class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Rover location</th>
<th>Station</th>
<th>Status</th>
<th>Distance</th>
<th>Min clearance</th>
<th>Diffraction</th>
<th>Verdict</th>
</tr>
</thead>
<tbody>
<tr :for={path <- @paths}>
<td class="font-mono text-xs">{rover_label(path)}</td>
<td class="font-mono text-xs">{station_label(path)}</td>
<td>{status_badge(path.status)}</td>
<td class="text-xs">
{if path.result, do: format_distance(path.result["distance_km"]), else: ""}
</td>
<td class="text-xs">
{if path.result,
do: "#{Float.round(path.result["min_clearance_m"] || 0.0, 1)} m",
else: ""}
</td>
<td class="text-xs">
{if path.result, do: format_db(path.result["diffraction_db"]), else: ""}
</td>
<td>
{if path.result, do: verdict_badge(path.result["verdict"]), else: ""}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</Layouts.app>
"""
end
end

View file

@ -198,6 +198,10 @@ defmodule MicrowavepropWeb.Router do
live "/rover", RoverLive
live "/rover-locations", RoverLocationsLive
live "/rover-locations/:id", RoverLocationsLive.Show
live "/rover-planning", RoverPlanningLive
live "/rover-planning/new", RoverPlanningLive.Form, :new
live "/rover-planning/:id", RoverPlanningLive.Show
live "/rover-planning/:id/edit", RoverPlanningLive.Form, :edit
live "/algo", AlgoLive
live "/about", AboutLive
live "/privacy", PrivacyLive

View file

@ -0,0 +1,65 @@
defmodule Microwaveprop.Repo.Migrations.CreateRoverPlanning do
use Ecto.Migration
def change do
create table(:rover_missions, primary_key: false) do
add :id, :binary_id, primary_key: true, null: false
add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false
add :name, :string, null: false
add :band_mhz, :integer, null: false, default: 10_000
add :only_known_good, :boolean, null: false, default: true
add :rover_height_ft, :float, null: false, default: 8.0
add :station_height_ft, :float, null: false, default: 30.0
add :notes, :text
timestamps(type: :utc_datetime)
end
create index(:rover_missions, [:user_id])
create index(:rover_missions, [:inserted_at])
create table(:rover_mission_stations, primary_key: false) do
add :id, :binary_id, primary_key: true, null: false
add :mission_id, references(:rover_missions, type: :binary_id, on_delete: :delete_all),
null: false
add :callsign, :string
add :grid, :string
add :input, :string, null: false
add :lat, :float, null: false
add :lon, :float, null: false
add :position, :integer, null: false, default: 0
timestamps(type: :utc_datetime)
end
create index(:rover_mission_stations, [:mission_id])
create table(:rover_mission_paths, primary_key: false) do
add :id, :binary_id, primary_key: true, null: false
add :mission_id, references(:rover_missions, type: :binary_id, on_delete: :delete_all),
null: false
add :rover_location_id,
references(:rover_locations, type: :binary_id, on_delete: :delete_all),
null: false
add :station_id,
references(:rover_mission_stations, type: :binary_id, on_delete: :delete_all),
null: false
add :status, :string, null: false, default: "pending"
add :result, :map
add :error, :text
add :computed_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create index(:rover_mission_paths, [:mission_id])
create index(:rover_mission_paths, [:status])
create unique_index(:rover_mission_paths, [:mission_id, :rover_location_id, :station_id],
name: :rover_mission_paths_unique
)
end
end

View file

@ -0,0 +1,144 @@
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: :ideal}, 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: :ideal})
_off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :off_limits})
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 :ideal 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 "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: :ideal})
_off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :off_limits})
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 = valid_attrs() |> Map.delete("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: :ideal})
{: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: :ideal})
{: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: :ideal})
{: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

View file

@ -130,9 +130,7 @@ defmodule MicrowavepropWeb.ContactMapLiveTest do
|> render_submit()
html =
render_click(
element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|)
)
render_click(element(lv, ~s|form:has(input.input-sm) button[phx-click="clear_dates"]|))
refute html =~ ~s(value="2025-01-01")
refute html =~ ~s(value="2026-01-01")

View file

@ -0,0 +1,115 @@
defmodule MicrowavepropWeb.RoverPlanningLiveTest do
use MicrowavepropWeb.ConnCase, async: true
import Phoenix.LiveViewTest
alias Microwaveprop.AccountsFixtures
alias Microwaveprop.Rover
alias Microwaveprop.RoverPlanning
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) do
{:ok, _} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal})
{: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" => %{"0" => %{"input" => "EM12kp", "position" => 0}}
})
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 "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 "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: :ideal})
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