diff --git a/test/towerops/coverages/buildings_test.exs b/test/towerops/coverages/buildings_test.exs new file mode 100644 index 00000000..b801e893 --- /dev/null +++ b/test/towerops/coverages/buildings_test.exs @@ -0,0 +1,85 @@ +defmodule Towerops.Coverages.BuildingsTest do + use Towerops.DataCase, async: false + + alias Towerops.Coverages.Building + alias Towerops.Coverages.Buildings + + describe "to_clutter/1" do + test "uses the recorded rooftop height when present" do + building = %Building{height_m: 12.0, geom: square_polygon(0.0, 0.0, 0.001)} + + assert %{height_m: 12.0, coords: coords} = Buildings.to_clutter(building) + assert is_list(coords) + assert length(coords) == 5 + end + + test "falls back to the default height when no value is recorded" do + building = %Building{height_m: nil, geom: square_polygon(0.0, 0.0, 0.001)} + + assert %{height_m: height} = Buildings.to_clutter(building) + assert height == Building.default_height_m() + end + + test "returns empty coords for a building whose geom isn't a polygon" do + building = %Building{height_m: 10.0, geom: nil} + assert %{coords: []} = Buildings.to_clutter(building) + end + end + + describe "for_bbox/1" do + test "returns clutter rows whose footprints intersect the bbox" do + inside = + Repo.insert!(%Building{ + source: "test", + height_m: 7.5, + ms_footprint_id: "in-1", + geom: square_polygon(-97.74, 30.27, 0.0005) + }) + + _outside = + Repo.insert!(%Building{ + source: "test", + height_m: 5.0, + ms_footprint_id: "out-1", + geom: square_polygon(-95.0, 28.0, 0.0005) + }) + + results = Buildings.for_bbox({-97.75, 30.26, -97.73, 30.28}) + + assert [clutter] = results + assert clutter.height_m == 7.5 + assert is_list(clutter.coords) + assert clutter.coords != [] + + # Sanity check: outside row really exists in the table. + assert Repo.aggregate(Building, :count) == 2 + assert Repo.get_by(Building, ms_footprint_id: inside.ms_footprint_id) + end + + test "returns an empty list when the bbox lands in unpopulated territory" do + Repo.insert!(%Building{ + source: "test", + height_m: 5.0, + ms_footprint_id: "anywhere", + geom: square_polygon(-97.74, 30.27, 0.0005) + }) + + assert [] = Buildings.for_bbox({0.0, 0.0, 0.001, 0.001}) + end + end + + defp square_polygon(lon, lat, side) do + %Geo.Polygon{ + coordinates: [ + [ + {lon, lat}, + {lon + side, lat}, + {lon + side, lat + side}, + {lon, lat + side}, + {lon, lat} + ] + ], + srid: 4326 + } + end +end diff --git a/test/towerops/coverages/raster_test.exs b/test/towerops/coverages/raster_test.exs new file mode 100644 index 00000000..100cbdbf --- /dev/null +++ b/test/towerops/coverages/raster_test.exs @@ -0,0 +1,110 @@ +defmodule Towerops.Coverages.RasterTest do + use Towerops.DataCase, async: false + + alias Towerops.Coverages.Coverage + alias Towerops.Coverages.Raster + + describe "query_rssi/3" do + test "returns :not_ready when raster_path is nil and status is computing" do + coverage = %Coverage{raster_path: nil, status: "computing"} + assert {:error, :not_ready} = Raster.query_rssi(coverage, 30.0, -97.0) + end + + test "returns :not_ready when raster_path is nil and status is ready (defensive)" do + coverage = %Coverage{raster_path: nil, status: "ready"} + assert {:error, :not_ready} = Raster.query_rssi(coverage, 30.0, -97.0) + end + + test "returns :outside_bbox when point is outside the bbox" do + coverage = %Coverage{ + raster_path: "/coverage/org/cov/rssi.tif", + status: "ready", + bbox_min_lat: 30.0, + bbox_min_lon: -98.0, + bbox_max_lat: 31.0, + bbox_max_lon: -97.0 + } + + assert {:error, :outside_bbox} = Raster.query_rssi(coverage, 25.0, -90.0) + end + + test "returns :raster_missing when bbox matches but file doesn't exist" do + coverage = %Coverage{ + raster_path: "/coverage/org/cov/rssi-does-not-exist.tif", + status: "ready", + bbox_min_lat: 30.0, + bbox_min_lon: -98.0, + bbox_max_lat: 31.0, + bbox_max_lon: -97.0 + } + + assert {:error, :raster_missing} = Raster.query_rssi(coverage, 30.5, -97.5) + end + + test "returns :outside_bbox when the coverage has no bbox at all" do + coverage = %Coverage{ + raster_path: "/coverage/org/cov/x.tif", + status: "ready", + bbox_min_lat: nil, + bbox_max_lat: nil, + bbox_min_lon: nil, + bbox_max_lon: nil + } + + assert {:error, :outside_bbox} = Raster.query_rssi(coverage, 30.5, -97.5) + end + end + + describe "cleanup/1" do + test "is a no-op when the coverage's output dir doesn't exist" do + coverage = %Coverage{ + organization_id: Ecto.UUID.generate(), + id: Ecto.UUID.generate() + } + + assert :ok = Raster.cleanup(coverage) + end + end + + describe "parse_aaigrid/1 (header parsing — public for testability)" do + alias Towerops.Lidar.Reader + + test "parses a small ASCII grid into the expected map shape" do + grid = """ + ncols 3 + nrows 2 + xllcorner -98.0 + yllcorner 30.0 + cellsize 0.5 + nodata_value -9999 + 1 2 3 + 4 5 6 + """ + + assert {:ok, parsed} = Reader.parse_aaigrid(grid) + assert parsed.ncols == 3 + assert parsed.nrows == 2 + assert parsed.cellsize == 0.5 + assert parsed.cells == [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]] + end + + test "treats missing nodata_value as the historical default of -9999.0" do + grid = """ + ncols 1 + nrows 1 + xllcorner 0.0 + yllcorner 0.0 + cellsize 1.0 + 42 + """ + + assert {:ok, parsed} = Reader.parse_aaigrid(grid) + assert parsed.nodata_value == -9999.0 + end + + test "returns an error tuple for grids missing required headers" do + grid = "ncols 3\nxllcorner -98.0\n" + assert {:error, {:missing_header, _}} = Reader.parse_aaigrid(grid) + end + end +end diff --git a/test/towerops/coverages_extra_test.exs b/test/towerops/coverages_extra_test.exs new file mode 100644 index 00000000..516d2c83 --- /dev/null +++ b/test/towerops/coverages_extra_test.exs @@ -0,0 +1,121 @@ +defmodule Towerops.CoveragesExtraTest do + @moduledoc """ + Extra coverage for `Towerops.Coverages` context functions not exercised + by the main `coverages_test.exs` suite — query_point, list_ready_for_organization, + topic helpers, subscribe/broadcast, change_coverage. + """ + use Towerops.DataCase, async: false + + import Towerops.CoveragesFixtures + import Towerops.OrganizationsFixtures + + alias Towerops.Coverages + alias Towerops.Coverages.Coverage + + setup do + user = Towerops.AccountsFixtures.user_fixture() + org = organization_fixture(user.id) + site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74}) + %{user: user, org: org, site: site} + end + + describe "topic/1 and org_topic/1" do + test "topic embeds the coverage id" do + assert Coverages.topic("abc") == "coverage:abc" + end + + test "org_topic embeds the org id" do + assert Coverages.org_topic("xyz") == "coverage:org:xyz" + end + end + + describe "subscribe/1 and broadcast/2" do + test "subscribe lets the caller receive a broadcast", %{org: org, site: site} do + cov = coverage_fixture(org.id, site.id) + + assert :ok = Coverages.subscribe(cov.id) + assert :ok = Coverages.broadcast(cov, {:hello, :world}) + + assert_receive {:hello, :world}, 500 + end + + test "subscribe_organization receives broadcasts for any coverage in the org", %{org: org, site: site} do + cov = coverage_fixture(org.id, site.id) + + assert :ok = Coverages.subscribe_organization(org.id) + assert :ok = Coverages.broadcast(cov, {:org_event, :ping}) + + assert_receive {:org_event, :ping}, 500 + end + end + + describe "list_ready_for_organization/1" do + test "returns only ready coverages", %{org: org, site: site} do + _draft = coverage_fixture(org.id, site.id, %{name: "Draft"}) + + ready_seed = coverage_fixture(org.id, site.id, %{name: "Ready"}) + + {:ok, _ready} = + Coverages.mark_status(ready_seed, "ready", %{ + png_path: "/coverage/x.png", + raster_path: "/coverage/x.tif", + bbox_min_lat: 30.0, + bbox_min_lon: -98.0, + bbox_max_lat: 30.5, + bbox_max_lon: -97.5 + }) + + result = Coverages.list_ready_for_organization(org.id) + names = Enum.map(result, & &1.name) + assert names == ["Ready"] + end + end + + describe "change_coverage/2" do + test "returns a changeset for a fresh struct" do + changeset = Coverages.change_coverage(%Coverage{}) + assert %Ecto.Changeset{} = changeset + end + + test "merges in attrs", %{site: site} do + changeset = Coverages.change_coverage(%Coverage{}, %{name: "Test", site_id: site.id}) + assert Ecto.Changeset.get_field(changeset, :name) == "Test" + end + end + + describe "query_point/3" do + test "returns a row per ready coverage with distance + rssi", %{org: org, site: site} do + cov = coverage_fixture(org.id, site.id, %{name: "QP-1"}) + + {:ok, _ready} = + Coverages.mark_status(cov, "ready", %{ + png_path: "/coverage/x.png", + raster_path: "/coverage/x.tif", + bbox_min_lat: 30.0, + bbox_min_lon: -98.0, + bbox_max_lat: 30.5, + bbox_max_lon: -97.5 + }) + + [row] = Coverages.query_point(org.id, 30.27, -97.74) + + assert row.coverage.name == "QP-1" + assert is_number(row.distance_m) + # rssi can be a number, :no_coverage, or nil — just assert it's one of those. + assert row.rssi == nil or row.rssi == :no_coverage or is_number(row.rssi) + end + + test "returns an empty list when org has no ready coverages", %{org: org} do + assert [] = Coverages.query_point(org.id, 30.27, -97.74) + end + end + + describe "queue_compute/1" do + test "rejects re-queueing a coverage already queued or computing", %{org: org, site: site} do + cov = coverage_fixture(org.id, site.id) + {:ok, queued} = Coverages.queue_compute(cov) + + assert {:error, :compute_in_progress} = Coverages.queue_compute(queued) + end + end +end diff --git a/test/towerops_web/live/coverage_live/form_test.exs b/test/towerops_web/live/coverage_live/form_test.exs index 0e9494d5..ec34d7b7 100644 --- a/test/towerops_web/live/coverage_live/form_test.exs +++ b/test/towerops_web/live/coverage_live/form_test.exs @@ -53,4 +53,31 @@ defmodule ToweropsWeb.CoverageLive.FormTest do end end end + + describe "save" do + test "saving with invalid attrs re-renders the form with errors", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/coverage/new") + + html = + view + |> form("form", coverage: %{"name" => "", "antenna_slug" => "simulate-isotropic-omni-0"}) + |> render_submit() + + # Either an inline error or the form re-rendered (no redirect). + assert html =~ "Coverage" or html =~ "blank" or html =~ "required" + end + end + + describe "location_picked / use_site_location events" do + test "location_picked stages the lat/lon override on the changeset", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/coverage/new") + + assert render_hook(view, "location_picked", %{"lat" => 30.27, "lon" => -97.74}) + end + + test "use_site_location clears the lat/lon override", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/coverage/new") + assert render_click(view, "use_site_location", %{}) + end + end end diff --git a/test/towerops_web/live/coverage_live/index_test.exs b/test/towerops_web/live/coverage_live/index_test.exs index 77860aa6..c54c5aa5 100644 --- a/test/towerops_web/live/coverage_live/index_test.exs +++ b/test/towerops_web/live/coverage_live/index_test.exs @@ -32,4 +32,45 @@ defmodule ToweropsWeb.CoverageLive.IndexTest do assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/coverage/list") assert path =~ "/users/log-in" end + + describe "events" do + test "delete removes the coverage and refreshes the list", %{conn: conn, org: org, site: site} do + cov = coverage_fixture(org.id, site.id, %{name: "Doomed"}) + {:ok, view, html} = live(conn, ~p"/coverage/list") + assert html =~ "Doomed" + + html = render_click(view, "delete", %{"id" => cov.id}) + refute html =~ "Doomed" + end + + test "recompute flips a draft coverage to queued and reloads", %{conn: conn, org: org, site: site} do + cov = coverage_fixture(org.id, site.id, %{name: "Recomp"}) + {:ok, view, _html} = live(conn, ~p"/coverage/list") + + html = render_click(view, "recompute", %{"id" => cov.id}) + assert html =~ "queued" + end + + test "recompute returns the in-progress flash if already computing", %{conn: conn, org: org, site: site} do + cov = coverage_fixture(org.id, site.id) + {:ok, _} = Towerops.Coverages.mark_status(cov, "computing", %{}) + + {:ok, view, _html} = live(conn, ~p"/coverage/list") + html = render_click(view, "recompute", %{"id" => cov.id}) + assert html =~ "already being computed" + end + end + + describe "status_badge_class/1" do + alias ToweropsWeb.CoverageLive.Index, as: Idx + + test "maps known statuses to themed classes" do + assert Idx.status_badge_class("ready") =~ "bg-green" + assert Idx.status_badge_class("failed") =~ "bg-red" + assert Idx.status_badge_class("computing") =~ "bg-yellow" + assert Idx.status_badge_class("queued") =~ "bg-blue" + assert Idx.status_badge_class("draft") =~ "bg-gray" + assert Idx.status_badge_class("anything-else") =~ "bg-gray" + end + end end