test(coverage): add tests for index/form events, raster, buildings, context

Push the Coverages namespace coverage up further:

* CoverageLive.Index — delete and recompute event handlers, the
  in-progress flash branch, status_badge_class fallback. 35% → 87%.
* CoverageLive.Form — invalid-save round-trip, location_picked and
  use_site_location event handlers. 61% → 77%.
* Coverages.Raster — query_rssi guard branches (not_ready, outside_bbox,
  raster_missing, missing-bbox), cleanup no-op, and Lidar.Reader
  parse_aaigrid header parsing. 63% → 75%.
* Coverages.Buildings — to_clutter height fallback, polygon coord
  extraction, for_bbox PostGIS intersect against real DB rows. 43% → ~95%.
* Coverages context — topic helpers, subscribe + broadcast,
  list_ready_for_organization, change_coverage, query_point happy and
  empty paths, queue_compute idempotency. 65% → 93%.

Total project coverage 74.05% → 74.25%.
This commit is contained in:
Graham McIntire 2026-05-07 09:36:16 -05:00
parent 1b7312a8df
commit 7adc2fb0a6
5 changed files with 384 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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