diff --git a/lib/microwaveprop/rover.ex b/lib/microwaveprop/rover.ex new file mode 100644 index 00000000..7742dec5 --- /dev/null +++ b/lib/microwaveprop/rover.ex @@ -0,0 +1,139 @@ +defmodule Microwaveprop.Rover do + @moduledoc """ + Context for managing a user's fixed stations and providing the + rover-planning defaults. + + All mutations are scoped to a user — `update_station/3`, `delete_station/2`, + and `toggle_selected/2` return `{:error, :not_found}` when invoked on + a station belonging to a different user. + """ + + import Ecto.Query + + alias Microwaveprop.Accounts.User + alias Microwaveprop.Radio.Maidenhead + alias Microwaveprop.Repo + alias Microwaveprop.Rover.FixedStation + + @default_stations [ + %{callsign: "W5LUA", grid: "EM13qc"}, + %{callsign: "W5HN", grid: "EM13nf"}, + %{callsign: "N5XU", grid: "EM10cl"} + ] + + @spec list_stations(User.t()) :: [FixedStation.t()] + def list_stations(%User{id: user_id}) do + FixedStation + |> where([s], s.user_id == ^user_id) + |> order_by([s], asc: s.position, asc: s.inserted_at) + |> Repo.all() + end + + @spec create_station(User.t(), map()) :: {:ok, FixedStation.t()} | {:error, Ecto.Changeset.t()} + def create_station(%User{} = user, attrs) do + attrs_with_position = Map.put_new(attrs, :position, next_position(user)) + + %FixedStation{user_id: user.id} + |> FixedStation.changeset(attrs_with_position) + |> Repo.insert() + |> maybe_enqueue_elevation() + end + + @spec update_station(User.t(), Ecto.UUID.t(), map()) :: + {:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()} + def update_station(%User{} = user, id, attrs) do + case fetch_owned(user, id) do + {:ok, station} -> + station + |> FixedStation.changeset(attrs) + |> Repo.update() + + {:error, :not_found} -> + {:error, :not_found} + end + end + + @spec delete_station(User.t(), Ecto.UUID.t()) :: + {:ok, FixedStation.t()} | {:error, :not_found} + def delete_station(%User{} = user, id) do + case fetch_owned(user, id) do + {:ok, station} -> + Repo.delete(station) + + {:error, :not_found} -> + {:error, :not_found} + end + end + + @spec toggle_selected(User.t(), Ecto.UUID.t()) :: + {:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()} + def toggle_selected(%User{} = user, id) do + case fetch_owned(user, id) do + {:ok, station} -> + station + |> Ecto.Changeset.change(selected: not station.selected) + |> Repo.update() + + {:error, :not_found} -> + {:error, :not_found} + end + end + + @doc """ + Three hardcoded NTMS stations used as the anonymous-user fallback in + the rover planner. Plain maps shaped like `FixedStation`, with lat/lon + derived from each grid via `Maidenhead.to_latlon/1`. + """ + @spec default_stations() :: [map()] + def default_stations do + @default_stations + |> Enum.with_index() + |> Enum.map(fn {%{callsign: callsign, grid: grid}, index} -> + {:ok, {lat, lon}} = Maidenhead.to_latlon(grid) + + %{ + callsign: callsign, + grid: grid, + lat: lat, + lon: lon, + elevation_m: nil, + selected: true, + position: index + } + end) + end + + defp fetch_owned(%User{id: user_id}, id) do + case Repo.get(FixedStation, id) do + %FixedStation{user_id: ^user_id} = station -> {:ok, station} + _ -> {:error, :not_found} + end + end + + defp next_position(%User{id: user_id}) do + query = + from s in FixedStation, + where: s.user_id == ^user_id, + select: count(s.id) + + Repo.one(query) || 0 + end + + defp maybe_enqueue_elevation({:ok, %FixedStation{elevation_m: nil, id: id}} = result) do + enqueue_station_elevation(id) + result + end + + defp maybe_enqueue_elevation(other), do: other + + # Wrapped behind a function-exported guard so this module compiles + # cleanly while Task 8's worker is still being authored — the worker + # is referenced by atom and resolved at runtime once it's loaded. + defp enqueue_station_elevation(id) do + worker = Microwaveprop.Workers.StationElevationWorker + + if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do + %{id: id} |> worker.new() |> Oban.insert() + end + end +end diff --git a/test/microwaveprop/rover_test.exs b/test/microwaveprop/rover_test.exs new file mode 100644 index 00000000..748bfdd5 --- /dev/null +++ b/test/microwaveprop/rover_test.exs @@ -0,0 +1,100 @@ +defmodule Microwaveprop.RoverTest do + use Microwaveprop.DataCase, async: true + + import Microwaveprop.AccountsFixtures + + alias Microwaveprop.Rover + alias Microwaveprop.Rover.FixedStation + + describe "list_stations/1" do + test "returns user's stations ordered by position asc" do + user = user_fixture() + {:ok, _b} = Rover.create_station(user, %{callsign: "B5BBB", lat: 33.0, lon: -96.0}) + {:ok, _a} = Rover.create_station(user, %{callsign: "A5AAA", lat: 33.0, lon: -96.0}) + + stations = Rover.list_stations(user) + + assert Enum.map(stations, & &1.callsign) == ["B5BBB", "A5AAA"] + assert Enum.map(stations, & &1.position) == [0, 1] + end + end + + describe "create_station/2" do + test "derives lat/lon from grid only" do + user = user_fixture() + {:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", grid: "EM13qc"}) + assert_in_delta station.lat, 33.10, 0.05 + assert_in_delta station.lon, -96.625, 0.05 + end + + test "errors with neither grid nor lat/lon" do + user = user_fixture() + assert {:error, %Ecto.Changeset{}} = Rover.create_station(user, %{callsign: "W5LUA"}) + end + + test "unique constraint surfaces as changeset error" do + user = user_fixture() + {:ok, _} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0}) + + assert {:error, %Ecto.Changeset{}} = + Rover.create_station(user, %{callsign: "W5LUA", lat: 34.0, lon: -97.0}) + end + end + + describe "update_station/3" do + test "foreign user returns :not_found" do + user = user_fixture() + other = user_fixture() + {:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0}) + + assert {:error, :not_found} = Rover.update_station(other, station.id, %{selected: false}) + end + end + + describe "delete_station/2" do + test "foreign user returns :not_found" do + user = user_fixture() + other = user_fixture() + {:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0}) + + assert {:error, :not_found} = Rover.delete_station(other, station.id) + end + + test "owner deletes" do + user = user_fixture() + {:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0}) + assert {:ok, %FixedStation{}} = Rover.delete_station(user, station.id) + assert Rover.list_stations(user) == [] + end + end + + describe "toggle_selected/2" do + test "flips boolean" do + user = user_fixture() + {:ok, station} = Rover.create_station(user, %{callsign: "W5LUA", lat: 33.0, lon: -96.0}) + assert station.selected == true + assert {:ok, updated} = Rover.toggle_selected(user, station.id) + assert updated.selected == false + assert {:ok, again} = Rover.toggle_selected(user, station.id) + assert again.selected == true + end + end + + describe "default_stations/0" do + test "returns 3 hardcoded NTMS stations" do + stations = Rover.default_stations() + assert length(stations) == 3 + callsigns = Enum.map(stations, & &1.callsign) + assert "W5LUA" in callsigns + assert "W5HN" in callsigns + assert "N5XU" in callsigns + assert Enum.all?(stations, & &1.selected) + assert Enum.map(stations, & &1.position) == [0, 1, 2] + + Enum.each(stations, fn s -> + assert is_float(s.lat) + assert is_float(s.lon) + end) + end + end +end