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:
parent
1b7312a8df
commit
7adc2fb0a6
5 changed files with 384 additions and 0 deletions
85
test/towerops/coverages/buildings_test.exs
Normal file
85
test/towerops/coverages/buildings_test.exs
Normal 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
|
||||
110
test/towerops/coverages/raster_test.exs
Normal file
110
test/towerops/coverages/raster_test.exs
Normal 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
|
||||
121
test/towerops/coverages_extra_test.exs
Normal file
121
test/towerops/coverages_extra_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue