defmodule Microwaveprop.RoverPlanningTest do use Microwaveprop.DataCase, async: false use Oban.Testing, repo: Microwaveprop.Repo alias Microwaveprop.AccountsFixtures alias Microwaveprop.Repo alias Microwaveprop.Rover alias Microwaveprop.RoverPlanning alias Microwaveprop.RoverPlanning.Station alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Workers.RoverPathProfileWorker 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 # Creates a mission and runs the full reconcile + compute pipeline under # manual Oban mode so that: # 1. The transaction inside reconcile_mission_paths/1 completes before any # profile worker executes (avoiding a deadlock where the transaction # holds the single sandbox connection while Task.async_stream tasks # spawned by PathCompute.compute also need the DB). # 2. Each RoverPathProfileWorker runs individually via perform_job, which # executes in the test process (the sandbox owner when shared: true). defp create_and_complete_mission!(user, attrs) do Oban.Testing.with_testing_mode(:manual, fn -> {:ok, mission} = RoverPlanning.create_mission(user, attrs) mission |> Repo.preload(stations: from(s in Station, order_by: s.position)) |> RoverPlanning.reconcile_mission_paths() mission |> RoverPlanning.list_paths() |> Enum.each(fn path -> perform_job(RoverPathProfileWorker, %{ "mission_id" => mission.id, "rover_location_id" => path.rover_location_id, "station_id" => path.station_id, "band_mhz" => path.band_mhz }) end) mission end) end # Runs backfill_paths/0 under manual Oban mode, then performs each # enqueued RoverPathProfileWorker job for the given mission so paths # land at :complete. defp run_backfill!(mission) do Oban.Testing.with_testing_mode(:manual, fn -> assert :ok = RoverPlanning.backfill_paths() mission |> RoverPlanning.list_paths() |> Enum.each(fn path -> perform_job(RoverPathProfileWorker, %{ "mission_id" => mission.id, "rover_location_id" => path.rover_location_id, "station_id" => path.station_id, "band_mhz" => path.band_mhz }) end) end) 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 x 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}) mission = create_and_complete_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) assert length(paths) == 1 assert hd(paths).status == :complete 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}) mission = create_and_complete_mission!(user, valid_attrs()) [path] = RoverPlanning.list_paths(mission) assert path.status == :complete 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. 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}) mission = create_and_complete_mission!(user, valid_attrs()) [path] = RoverPlanning.list_paths(mission) 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}) mission = create_and_complete_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() run_backfill!(mission) [reloaded] = RoverPlanning.list_paths(mission) assert reloaded.status == :complete assert map_size(reloaded.result) > 0 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}) mission = create_and_complete_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) == [] run_backfill!(mission) assert [path] = RoverPlanning.list_paths(mission) assert path.status == :complete end end end