From d1a5442afb1cf879b88ef37495492c54bc72c016 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 3 May 2026 11:04:11 -0500 Subject: [PATCH] feat(rover-planning): /rover-planning page with path-profile worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop/rover_planning.ex | 197 ++++++++++++ lib/microwaveprop/rover_planning/mission.ex | 71 +++++ lib/microwaveprop/rover_planning/path.ex | 56 ++++ lib/microwaveprop/rover_planning/station.ex | 86 +++++ .../workers/rover_path_profile_worker.ex | 177 +++++++++++ .../live/rover_planning_live.ex | 203 ++++++++++++ .../live/rover_planning_live/form.ex | 299 ++++++++++++++++++ .../live/rover_planning_live/show.ex | 264 ++++++++++++++++ lib/microwaveprop_web/router.ex | 4 + .../20260503155617_create_rover_planning.exs | 65 ++++ test/microwaveprop/rover_planning_test.exs | 144 +++++++++ .../live/contact_map_live_test.exs | 4 +- .../live/rover_planning_live_test.exs | 115 +++++++ 13 files changed, 1682 insertions(+), 3 deletions(-) create mode 100644 lib/microwaveprop/rover_planning.ex create mode 100644 lib/microwaveprop/rover_planning/mission.ex create mode 100644 lib/microwaveprop/rover_planning/path.ex create mode 100644 lib/microwaveprop/rover_planning/station.ex create mode 100644 lib/microwaveprop/workers/rover_path_profile_worker.ex create mode 100644 lib/microwaveprop_web/live/rover_planning_live.ex create mode 100644 lib/microwaveprop_web/live/rover_planning_live/form.ex create mode 100644 lib/microwaveprop_web/live/rover_planning_live/show.ex create mode 100644 priv/repo/migrations/20260503155617_create_rover_planning.exs create mode 100644 test/microwaveprop/rover_planning_test.exs create mode 100644 test/microwaveprop_web/live/rover_planning_live_test.exs diff --git a/lib/microwaveprop/rover_planning.ex b/lib/microwaveprop/rover_planning.ex new file mode 100644 index 00000000..6b5009fa --- /dev/null +++ b/lib/microwaveprop/rover_planning.ex @@ -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 diff --git a/lib/microwaveprop/rover_planning/mission.ex b/lib/microwaveprop/rover_planning/mission.ex new file mode 100644 index 00000000..2d292e1e --- /dev/null +++ b/lib/microwaveprop/rover_planning/mission.ex @@ -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 diff --git a/lib/microwaveprop/rover_planning/path.ex b/lib/microwaveprop/rover_planning/path.ex new file mode 100644 index 00000000..7ee149d1 --- /dev/null +++ b/lib/microwaveprop/rover_planning/path.ex @@ -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 diff --git a/lib/microwaveprop/rover_planning/station.ex b/lib/microwaveprop/rover_planning/station.ex new file mode 100644 index 00000000..9535710c --- /dev/null +++ b/lib/microwaveprop/rover_planning/station.ex @@ -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 diff --git a/lib/microwaveprop/workers/rover_path_profile_worker.ex b/lib/microwaveprop/workers/rover_path_profile_worker.ex new file mode 100644 index 00000000..e14a6c09 --- /dev/null +++ b/lib/microwaveprop/workers/rover_path_profile_worker.ex @@ -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 diff --git a/lib/microwaveprop_web/live/rover_planning_live.ex b/lib/microwaveprop_web/live/rover_planning_live.ex new file mode 100644 index 00000000..d55e39b7 --- /dev/null +++ b/lib/microwaveprop_web/live/rover_planning_live.ex @@ -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""" +
+ <.link + navigate={~p"/rover-planning/#{@record.id}"} + class="btn btn-xs btn-ghost" + > + View + + <.link + :if={@can_modify?} + navigate={~p"/rover-planning/#{@record.id}/edit"} + class="btn btn-xs btn-ghost" + > + Edit + + +
+ """ + 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} + + """ + 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|Known good only| + end + + defp scope_cell(false) do + assigns = %{} + ~H|All locations| + 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""" + + <.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. + + <: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 + + + + +

+ Sign in to plan a mission. +

+ + <.live_table + fields={fields()} + filters={filters()} + options={@options} + streams={@streams} + actions={actions_for(@current_scope)} + /> +
+ """ + end +end diff --git a/lib/microwaveprop_web/live/rover_planning_live/form.ex b/lib/microwaveprop_web/live/rover_planning_live/form.ex new file mode 100644 index 00000000..7c70d08e --- /dev/null +++ b/lib/microwaveprop_web/live/rover_planning_live/form.ex @@ -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""" + + <.header> + {if @mode == :edit, do: "Edit mission", else: "New rover mission"} + <:subtitle> + Stationary stations can be callsigns, Maidenhead grids, or lat, lon pairs. + + + + <.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" /> + +
+ <.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)" + /> +
+ + + +
+
+

Stationary stations

+ +
+ + <.inputs_for :let={s} field={@form[:stations]}> +
+
+ <.input + field={s[:input]} + type="text" + label="Callsign / grid / lat,lon" + placeholder="W5LUA or EM13qc or 32.91, -97.06" + /> +
+
+ <.input field={s[:lat]} type="number" step="any" label="Lat" /> +
+
+ <.input field={s[:lon]} type="number" step="any" label="Lon" /> +
+ +
+
+

+ Resolved callsign: {s[:callsign].value} + · grid {s[:grid].value} +

+
+ + +

+ {Phoenix.HTML.html_escape(Enum.map_join(@form[:stations].errors, ", ", &elem(&1, 0)))} +

+
+ + <.input field={@form[:notes]} type="textarea" label="Notes" rows="3" /> + +
+ + <.link navigate={~p"/rover-planning"} class="btn btn-ghost">Cancel +
+ +
+ """ + end +end diff --git a/lib/microwaveprop_web/live/rover_planning_live/show.ex b/lib/microwaveprop_web/live/rover_planning_live/show.ex new file mode 100644 index 00000000..df70c58d --- /dev/null +++ b/lib/microwaveprop_web/live/rover_planning_live/show.ex @@ -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|Complete| + end + + defp status_badge(:computing) do + assigns = %{} + ~H|Computing| + end + + defp status_badge(:failed) do + assigns = %{} + ~H|Failed| + end + + defp status_badge(_) do + assigns = %{} + ~H|Pending| + end + + defp verdict_badge("clear") do + assigns = %{} + ~H|Clear| + end + + defp verdict_badge("blocked") do + assigns = %{} + ~H|Blocked| + end + + defp verdict_badge("marginal") do + assigns = %{} + ~H|Marginal| + 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""" + + <.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 + + <: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 + :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 + + + + + +
+

Stationary stations

+
    +
  • + {station.callsign || station.grid || station.input} + grid {station.grid} + + {Float.round(station.lat, 4)}, {Float.round(station.lon, 4)} + +
  • +
+ +

+ {@mission.notes} +

+ +

+ Scope: {if @mission.only_known_good, + do: "Known good locations only (status = ideal)", + else: "All rover locations"} · Owner: {(@mission.user && @mission.user.callsign) || "—"} +

+
+ +
+
+

Path profiles

+
+ {@summary.complete}/{@summary.total} complete + 0}>· {@summary.pending} pending + 0} class="text-error"> + · {@summary.failed} failed + +
+
+ +
+ No paths yet — they'll appear here as the workers run. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
Rover locationStationStatusDistanceMin clearanceDiffractionVerdict
{rover_label(path)}{station_label(path)}{status_badge(path.status)} + {if path.result, do: format_distance(path.result["distance_km"]), else: "—"} + + {if path.result, + do: "#{Float.round(path.result["min_clearance_m"] || 0.0, 1)} m", + else: "—"} + + {if path.result, do: format_db(path.result["diffraction_db"]), else: "—"} + + {if path.result, do: verdict_badge(path.result["verdict"]), else: "—"} +
+
+
+
+ """ + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 79eaaead..65068ab6 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -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 diff --git a/priv/repo/migrations/20260503155617_create_rover_planning.exs b/priv/repo/migrations/20260503155617_create_rover_planning.exs new file mode 100644 index 00000000..2edee094 --- /dev/null +++ b/priv/repo/migrations/20260503155617_create_rover_planning.exs @@ -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 diff --git a/test/microwaveprop/rover_planning_test.exs b/test/microwaveprop/rover_planning_test.exs new file mode 100644 index 00000000..6125363a --- /dev/null +++ b/test/microwaveprop/rover_planning_test.exs @@ -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 diff --git a/test/microwaveprop_web/live/contact_map_live_test.exs b/test/microwaveprop_web/live/contact_map_live_test.exs index e3730597..bcf10662 100644 --- a/test/microwaveprop_web/live/contact_map_live_test.exs +++ b/test/microwaveprop_web/live/contact_map_live_test.exs @@ -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") diff --git a/test/microwaveprop_web/live/rover_planning_live_test.exs b/test/microwaveprop_web/live/rover_planning_live_test.exs new file mode 100644 index 00000000..c331ad19 --- /dev/null +++ b/test/microwaveprop_web/live/rover_planning_live_test.exs @@ -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