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: :good}, 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: :good}) _off_limits = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad}) 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 :good 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 "completed path caches the link-budget loss components" do user = AccountsFixtures.user_fixture() _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs()) [path] = RoverPlanning.list_paths(mission) assert path.status == :complete # The result map MUST carry the cached loss-budget so the show # page (and any future caller) can render link math without # re-running the computation. Values come from the band's # absorption coefficients × distance + free-space path loss + the # already-cached terrain diffraction. Stored as floats. assert is_float(path.result["free_space_loss_db"]) assert is_float(path.result["oxygen_loss_db"]) assert is_float(path.result["humidity_loss_db_baseline"]) assert is_float(path.result["total_baseline_loss_db"]) # Free-space loss for a ~140 km, 10 GHz path is ~155 dB; sanity # check it's in the right order of magnitude (not zero, not 1000). assert path.result["free_space_loss_db"] > 100 assert path.result["free_space_loss_db"] < 200 end test "completed path stores a propagation_score key (may be nil without scores file)" do user = AccountsFixtures.user_fixture() _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs()) [path] = RoverPlanning.list_paths(mission) # The worker is the gate: status only flips to :complete after the # path-calculator pipeline (terrain + propagation lookup) has run. # The score itself is nil in the test env (no .prop file on disk), # but the key MUST be present so the show-page can render "—" # without a key-existence check at every cell. assert path.status == :complete assert Map.has_key?(path.result, "propagation_score") end test "backfill_paths/0 re-runs failed paths across all missions" do user = AccountsFixtures.user_fixture() _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs()) [path] = RoverPlanning.list_paths(mission) assert path.status == :complete # Simulate a path that was stuck in :failed (e.g. transient # elevation API error during the original computation). {:ok, _} = path |> Ecto.Changeset.change(%{status: :failed, error: "transient", result: nil}) |> Repo.update() assert :ok = RoverPlanning.backfill_paths() # Oban inline → backfill enqueues a job, the worker reruns # immediately, and the path flips back to :complete. [reloaded] = RoverPlanning.list_paths(mission) assert reloaded.status == :complete assert is_map(reloaded.result) end test "backfill_paths/0 inserts missing rows for under-populated missions" do user = AccountsFixtures.user_fixture() _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) assert {:ok, mission} = RoverPlanning.create_mission(user, valid_attrs()) # Simulate a half-broken mission: paths were never persisted (e.g. # the original create_mission Multi succeeded but the Oban insert # was lost due to a crash). Backfill must repopulate the matrix. Repo.delete_all(Ecto.Query.from(p in RoverPlanning.Path, where: p.mission_id == ^mission.id)) assert RoverPlanning.list_paths(mission) == [] assert :ok = RoverPlanning.backfill_paths() assert [path] = RoverPlanning.list_paths(mission) assert path.status == :complete end test "multi-band mission inserts one path per (rover, station, band)" do user = AccountsFixtures.user_fixture() _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) attrs = valid_attrs(%{ "bands_mhz" => [10_000, 24_000, 47_000], # Form may still include band_mhz (legacy primary band); normalize # should overwrite it from bands_mhz in any case. "band_mhz" => 10_000 }) assert {:ok, mission} = RoverPlanning.create_mission(user, attrs) assert mission.bands_mhz == [10_000, 24_000, 47_000] paths = RoverPlanning.list_paths(mission) assert length(paths) == 3 assert Enum.sort(Enum.map(paths, & &1.band_mhz)) == [10_000, 24_000, 47_000] end test "reconcile drops paths whose band is no longer requested" do user = AccountsFixtures.user_fixture() _loc = create_rover_location(%{lat: 32.0, lon: -97.0, status: :good}) attrs = valid_attrs(%{"bands_mhz" => [10_000, 24_000]}) assert {:ok, mission} = RoverPlanning.create_mission(user, attrs) assert length(RoverPlanning.list_paths(mission)) == 2 # User narrows the mission to a single band — reconcile should # remove the now-unused 24 GHz row, keep the 10 GHz row. mission = mission |> Mission.changeset(%{"bands_mhz" => [10_000]}) |> Repo.update!() |> Repo.preload([:user, :stations], force: true) :ok = RoverPlanning.replace_mission_paths(mission) paths = RoverPlanning.list_paths(mission) assert length(paths) == 1 assert hd(paths).band_mhz == 10_000 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: :good}) _off = create_rover_location(%{lat: 33.0, lon: -98.0, status: :bad}) 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 = Map.delete(valid_attrs(), "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: :good}) {: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: :good}) {: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: :good}) {: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