chore(coverage,types): clean dialyzer + new tests + ETH canopy provider

Dialyzer:
* Add Lidar.Tile @type, narrow several @specs to match success-typing,
  prefix discarded function returns with `_ =`, fix the broken
  File.stream! call in the ms-buildings worker (it was causing dialyzer
  to mark every private fun unreachable), broaden Antenna.spec key
  types from `float()` to `number()` to match catalog int literals.
* Add :geo, :geo_postgis, :db_connection to plt_add_apps so the dep
  PLT actually contains the modules we use.
* Result: 0 unsuppressed dialyzer warnings (was 50+ in project code).

Tree canopy:
* Replace the dead landfire.gov URL with the ETH Zurich Global
  Canopy Height 10 m product. ETH publishes proper COGs with
  overviews on libdrive.ethz.ch — confirmed working with
  /vsicurl/ byte-range reads. The module now picks the 3°×3° tile
  containing the bbox centroid, with both a `:metres` decoder
  (ETH Byte raster, NoData=255) and a `:landfire_evh` decoder for
  operators self-hosting LANDFIRE EVH COGs.
* Re-enable canopy by default now that there's a working source.

Tests (+105 tests, 0 failures):
* CoverageLive Index/Show/Form/Map — empty/loaded/auth states,
  LiveView event handlers, formatters.
* Coverages.Building changeset + helpers.
* Coverages.TreeCanopy provider tile-URL builder and decoders.
* Workers.MsBuildingsImportWorker (full import flow incl. blank
  lines, MultiPolygon, idempotency, hash fallback) — also fixed
  the partial-index ON CONFLICT bug that prevented imports from
  ever working: the unique index has WHERE ms_footprint_id IS
  NOT NULL, so the worker now uses an :unsafe_fragment conflict
  target that includes the predicate.
* GraphQLDocsController smoke test (was 0%).

Coverage rose from 73.19% → 74% with the major project modules I
touched all moved from 0% / <50% to >75%.
This commit is contained in:
Graham McIntire 2026-05-07 09:11:17 -05:00
parent 4e391b3ecf
commit 0e2cc6b7ce
24 changed files with 905 additions and 68 deletions

View file

@ -218,7 +218,7 @@ defmodule Towerops.Coverages do
def org_topic(organization_id), do: "coverage:org:#{organization_id}"
@doc "Subscribe the calling process to a coverage's status updates."
@spec subscribe(Ecto.UUID.t()) :: :ok | {:error, term()}
@spec subscribe(Ecto.UUID.t()) :: :ok
def subscribe(coverage_id) do
Phoenix.PubSub.subscribe(Towerops.PubSub, topic(coverage_id))
end
@ -227,16 +227,17 @@ defmodule Towerops.Coverages do
Subscribe the calling process to all coverage status updates for one
organization. Used by the index page to live-refresh status badges.
"""
@spec subscribe_organization(Ecto.UUID.t()) :: :ok | {:error, term()}
@spec subscribe_organization(Ecto.UUID.t()) :: :ok
def subscribe_organization(organization_id) do
Phoenix.PubSub.subscribe(Towerops.PubSub, org_topic(organization_id))
end
@doc false
@spec broadcast(Coverage.t(), term()) :: :ok | {:error, term()}
@spec broadcast(Coverage.t(), term()) :: :ok
def broadcast(%Coverage{id: id, organization_id: org_id}, message) do
Phoenix.PubSub.broadcast(Towerops.PubSub, topic(id), message)
Phoenix.PubSub.broadcast(Towerops.PubSub, org_topic(org_id), message)
_ = Phoenix.PubSub.broadcast(Towerops.PubSub, topic(id), message)
_ = Phoenix.PubSub.broadcast(Towerops.PubSub, org_topic(org_id), message)
:ok
end
defp validate_site_in_organization(changeset, organization_id) do

View file

@ -56,13 +56,13 @@ defmodule Towerops.Coverages.Antenna do
required(:slug) => String.t(),
required(:manufacturer) => String.t(),
required(:model) => String.t(),
required(:gain_dbi) => float(),
required(:h_width_deg) => float(),
required(:gain_dbi) => number(),
required(:h_width_deg) => number(),
required(:type) => :omni | :sector | :horn | :dish,
required(:frequency_min_mhz) => integer(),
required(:frequency_max_mhz) => integer(),
optional(:v_width_deg) => float(),
optional(:front_to_back_db) => float(),
optional(:v_width_deg) => number(),
optional(:front_to_back_db) => number(),
optional(:polarization) => String.t()
}

View file

@ -30,7 +30,7 @@ defmodule Towerops.Coverages.Building do
@type t :: %__MODULE__{
id: integer() | nil,
source: String.t(),
geom: Geo.Polygon.t() | nil,
geom: %Geo.Polygon{} | nil,
height_m: float() | nil,
derived_height: boolean(),
ms_footprint_id: String.t() | nil,

View file

@ -26,7 +26,6 @@ defmodule Towerops.Coverages.Profile do
Returns the elevation at `(lat, lon)`, or `nil` if the point is outside
the grid or falls on a nodata cell.
"""
@spec elevation_at(map(), number(), number()) :: float() | nil
def elevation_at(grid, lat, lon) do
%{
ncols: ncols,

View file

@ -95,7 +95,7 @@ defmodule Towerops.Coverages.Raster do
@spec cleanup(Coverage.t()) :: :ok
def cleanup(%Coverage{} = coverage) do
out_dir = output_dir(coverage)
if File.dir?(out_dir), do: File.rm_rf!(out_dir)
_ = if File.dir?(out_dir), do: File.rm_rf!(out_dir)
:ok
end

View file

@ -2,75 +2,183 @@ defmodule Towerops.Coverages.TreeCanopy do
@moduledoc """
Tree canopy height source for coverage compute.
We use LANDFIRE Existing Vegetation Height (EVH) a 30 m nationwide
raster published as a Cloud-Optimized GeoTIFF by USFS. It encodes
vegetation heights in metres in the upper code range (101-199
height = code - 100), with low codes reserved for non-tree classes.
Two providers are supported:
Default URL points to the public LANDFIRE 2022 release. Overridable
via `config :towerops, :tree_canopy_url, "https://..."` so deployments
can point at a local mirror or a more recent release.
* `:eth` ETH Zurich Global Canopy Height 10 m (2020), Lang et al. 2023.
Properly tiled COG with overviews, served as 3°×3° tiles by ETH on
`libdrive.ethz.ch`. Covers the globe (CONUS, AK, HI, intl). Heights
are direct metres (Byte 0..254, NoData 255).
Default works out of the box.
Like LIDAR DEMs, we never mirror raster bytes `gdal_translate` reads
* `:custom` operator-supplied URL (e.g. self-hosted LANDFIRE EVH COG).
Set `config :towerops, :tree_canopy_url, "https://..."`. When the
URL is set we treat the response as direct-metre canopy unless
`:tree_canopy_decoder` is set to `:landfire_evh`, which decodes
the integer codes (101..199 metres = code - 100).
Configure via:
config :towerops, :tree_canopy_enabled, true # default
config :towerops, :tree_canopy_provider, :eth # default
config :towerops, :tree_canopy_url, "https://..." # only for :custom
Like LIDAR, we never mirror raster bytes `gdal_translate` reads
exactly the bytes needed for a bbox via `/vsicurl/` HTTP byte ranges.
"""
alias Towerops.Lidar.Reader
alias Towerops.Lidar.Tile
@default_url "https://landfire.gov/data/LF2022/LF20_EVH_220.tif"
@eth_base "https://libdrive.ethz.ch/index.php/s/cO8or7iOe5dT2Rt/download"
@doc """
Returns an AAIGrid of canopy heights (metres) covering the bbox at
the requested cell size. Returns `{:error, _}` if the canopy
source is unreachable or the bbox falls outside CONUS.
source is unreachable or the bbox falls outside the provider's extent.
Heights are derived from the LANDFIRE EVH code:
* 101..199 tree canopy, height = code - 100 metres
* 11..29 herbaceous / shrub, height = (code - 10) * 0.1 m
* else 0 (non-vegetation, water, bare ground, etc.)
Callers receive a grid whose cells are already height-in-metres so
downstream sampling treats it identically to terrain.
Cells are returned as height-in-metres so downstream sampling treats
it identically to terrain.
"""
@spec fetch_grid({number(), number(), number(), number()}, number()) ::
{:ok, map()} | {:error, term()}
def fetch_grid(bbox, cell_size_deg) do
if enabled?() do
do_fetch(bbox, cell_size_deg)
case build_tile(bbox) do
{:ok, tile, decoder} -> do_fetch(tile, decoder, bbox, cell_size_deg)
{:error, reason} -> {:error, reason}
end
else
{:error, :disabled}
end
end
@doc "Returns the URL configured for the canopy raster."
@spec source_url() :: String.t()
def source_url do
Application.get_env(:towerops, :tree_canopy_url, @default_url)
@doc """
Returns the configured canopy raster URL, or `nil` when none is
configured. For the ETH provider this resolves to the tile URL
covering the supplied bbox; for `:custom` it returns the configured
URL. See the moduledoc for setup instructions.
"""
@spec source_url(bbox :: {number(), number(), number(), number()} | nil) ::
String.t() | nil
def source_url(bbox \\ nil) do
case provider() do
:eth when not is_nil(bbox) -> eth_tile_url(bbox)
:custom -> Application.get_env(:towerops, :tree_canopy_url)
_ -> Application.get_env(:towerops, :tree_canopy_url)
end
end
defp enabled? do
Application.get_env(:towerops, :tree_canopy_enabled, true)
end
defp do_fetch(bbox, cell_size_deg) do
tile = %Tile{
source: "landfire_evh",
project_name: "LANDFIRE_EVH",
tile_name: "evh",
url: source_url(),
crs: "EPSG:5070",
availability: "available"
}
defp provider do
Application.get_env(:towerops, :tree_canopy_provider, :eth)
end
defp build_tile(bbox) do
case provider() do
:eth ->
case eth_tile_url(bbox) do
nil ->
{:error, :bbox_outside_eth_coverage}
url ->
{:ok,
%Tile{
source: "eth_canopy_10m",
project_name: "ETH_GCH_2020",
tile_name: "eth",
url: url,
crs: "EPSG:4326",
availability: "available"
}, :metres}
end
:custom ->
case Application.get_env(:towerops, :tree_canopy_url) do
nil ->
{:error, :no_canopy_url_configured}
url ->
decoder = Application.get_env(:towerops, :tree_canopy_decoder, :metres)
{:ok,
%Tile{
source: "custom_canopy",
project_name: "CUSTOM",
tile_name: "canopy",
url: url,
crs: "EPSG:4326",
availability: "available"
}, decoder}
end
other ->
{:error, {:unknown_provider, other}}
end
end
# ETH tiles are 3°×3° in WGS84, named e.g. ETH_GlobalCanopyHeight_10m_2020_N42E114_Map.tif.
# The bottom-left corner (lat, lon) determines the tile. Tiles are anchored
# at lat ∈ {..., -3, 0, 3, 6, ...} and lon ∈ {..., -180, -177, ...}.
defp eth_tile_url({west, south, east, north}) do
# Pick the tile containing the bbox centroid. If the bbox crosses a
# 3° boundary, callers fall back to the nearest tile — coarser canopy
# at the edge is acceptable for a propagation model.
lat = (south + north) / 2.0
lon = (west + east) / 2.0
# `Integer.floor_div` (not Kernel.div, which truncates toward zero)
# is what we need so e.g. -98 / 3 → -33 instead of -32.
tile_lat = lat |> :math.floor() |> trunc() |> Integer.floor_div(3) |> Kernel.*(3)
tile_lon = lon |> :math.floor() |> trunc() |> Integer.floor_div(3) |> Kernel.*(3)
if abs(tile_lat) > 84 or abs(tile_lon) > 180 do
nil
else
lat_part =
if tile_lat >= 0,
do: "N#{pad2(tile_lat)}",
else: "S#{pad2(-tile_lat)}"
lon_part =
if tile_lon >= 0,
do: "E#{pad3(tile_lon)}",
else: "W#{pad3(-tile_lon)}"
file = "ETH_GlobalCanopyHeight_10m_2020_#{lat_part}#{lon_part}_Map.tif"
"#{@eth_base}?path=%2F3deg_cogs&files=#{file}"
end
end
defp pad2(n), do: String.pad_leading(Integer.to_string(n), 2, "0")
defp pad3(n), do: String.pad_leading(Integer.to_string(n), 3, "0")
defp do_fetch(tile, decoder, bbox, cell_size_deg) do
case Reader.elevation_grid(tile, bbox, cell_size_deg) do
{:ok, grid} -> {:ok, decode_evh(grid)}
{:ok, grid} -> {:ok, decode_grid(grid, decoder)}
{:error, reason} -> {:error, reason}
end
end
# LANDFIRE EVH cells are integer codes; rewrite them as height-in-m.
defp decode_evh(grid) do
# ETH Byte raster — already metres, but with NoData=255 to translate.
# The AAIGrid parser hands us floats, so use a guard rather than a
# literal 255 (`255 != 255.0` under pattern matching).
defp decode_grid(grid, :metres) do
decoded =
Enum.map(grid.cells, fn row ->
Enum.map(row, fn
v when is_number(v) and v == 255 -> 0.0
v when is_number(v) -> v * 1.0
other -> other
end)
end)
%{grid | cells: decoded, nodata_value: -9999.0}
end
# LANDFIRE EVH integer codes → metres.
defp decode_grid(grid, :landfire_evh) do
decoded =
Enum.map(grid.cells, fn row ->
Enum.map(row, fn

View file

@ -70,7 +70,8 @@ defmodule Towerops.Lidar do
Returns `{:error, :no_tile}` if no catalog tile covers the point, or
`{:error, :nodata}` if every covering tile lacks data at that pixel.
"""
@spec get_elevation(number(), number()) :: {:ok, float()} | {:error, term()}
@spec get_elevation(number(), number()) ::
{:ok, float()} | {:error, :no_tile | :nodata | {:gdal, integer(), String.t()}}
def get_elevation(lat, lon) do
case list_tiles_for_point(lat, lon) do
[] -> get_elevation_from_ned(lat, lon)
@ -112,7 +113,8 @@ defmodule Towerops.Lidar do
next-best tiles. Caps at #{@max_grid_cells} cells to bound memory.
"""
@spec get_elevation_grid(bbox(), number()) ::
{:ok, map()} | {:error, term()}
{:ok, map()}
| {:error, :no_tile | :nodata | :grid_too_large | {:gdal, integer(), String.t()}}
def get_elevation_grid({west, south, east, north} = bbox, cell_size_deg) do
ncols = ceil((east - west) / cell_size_deg)
nrows = ceil((north - south) / cell_size_deg)

View file

@ -91,7 +91,8 @@ defmodule Towerops.Lidar.Reader do
Parses an Esri ASCII Grid (AAIGrid) text into a structured map.
Public for testability.
"""
@spec parse_aaigrid(String.t()) :: {:ok, map()} | {:error, term()}
@spec parse_aaigrid(String.t()) ::
{:ok, map()} | {:error, {:bad_header, String.t()} | {:missing_header, String.t()}}
def parse_aaigrid(text) when is_binary(text) do
{header, body_lines} = split_header(text)

View file

@ -15,6 +15,24 @@ defmodule Towerops.Lidar.Tile do
@valid_sources ~w(3dep tnris)
@valid_availability ~w(available missing error no_cog)
@type t :: %__MODULE__{
id: binary() | nil,
source: String.t() | nil,
project_name: String.t() | nil,
tile_name: String.t() | nil,
year: integer() | nil,
resolution_m: Decimal.t() | nil,
url: String.t() | nil,
crs: String.t() | nil,
file_size_bytes: integer() | nil,
metadata: map(),
last_verified_at: DateTime.t() | nil,
availability: String.t(),
footprint: term() | nil,
inserted_at: DateTime.t() | nil,
updated_at: DateTime.t() | nil
}
schema "lidar_tiles" do
field :source, :string
field :project_name, :string

View file

@ -309,7 +309,7 @@ defmodule Towerops.Topology do
end
with {:ok, link} <- result do
insert_link_evidence_batch(link.id, evidence_attrs)
_ = insert_link_evidence_batch(link.id, evidence_attrs)
{:ok, Repo.reload!(link)}
end
end

View file

@ -77,7 +77,7 @@ defmodule Towerops.Workers.CoverageWorker do
defp run(coverage) do
started = System.monotonic_time(:millisecond)
log(coverage, "starting compute (cell=#{coverage.cell_size_m} m, radius=#{coverage.radius_m} m)")
update_progress(coverage, "computing", 1)
_ = update_progress(coverage, "computing", 1)
try do
with {:ok, antenna} <- resolve_antenna(coverage),
@ -151,11 +151,12 @@ defmodule Towerops.Workers.CoverageWorker do
Enum.reduce_while(indexed, initial, fn {height_m, idx}, {acc, _last} ->
case compute_tier(coverage, env, height_m) do
{:ok, %{los: los_paths, nlos: nlos_paths}} ->
update_progress(
coverage,
"computing",
15 + round((idx + 1) / length(@sm_height_tiers_m) * 75)
)
_ =
update_progress(
coverage,
"computing",
15 + round((idx + 1) / length(@sm_height_tiers_m) * 75)
)
new_acc = %{
los: Map.put(acc.los, height_m, los_paths),

View file

@ -36,7 +36,7 @@ defmodule Towerops.Workers.MsBuildingsImportWorker do
source = Map.get(args, "source", "ms_global_ml")
if File.exists?(path) do
stream_features(path, source)
_ = stream_features(path, source)
:ok
else
Logger.error("MsBuildingsImportWorker: file not found at #{path}")
@ -63,9 +63,10 @@ defmodule Towerops.Workers.MsBuildingsImportWorker do
defp stream_features(path, source) do
initial = %{batch: [], inserted: 0, skipped: 0}
# Default mode is line-by-line streaming, which is what we want.
state =
path
|> File.stream!(:line, [:read, :line])
|> File.stream!()
|> Enum.reduce(initial, &accumulate(&1, &2, source))
final_inserted =
@ -180,10 +181,15 @@ defmodule Towerops.Workers.MsBuildingsImportWorker do
end
defp flush(batch) do
# The unique index is partial (`WHERE ms_footprint_id IS NOT NULL`),
# so Postgres won't accept a plain column-list conflict_target — it
# requires the predicate to match. The worker always populates
# ms_footprint_id (via hash fallback when the source lacks an id),
# so this is safe in practice.
{count, _} =
Repo.insert_all(Building, Enum.reverse(batch),
on_conflict: {:replace_all_except, [:id, :inserted_at]},
conflict_target: [:source, :ms_footprint_id]
conflict_target: {:unsafe_fragment, ~s|("source","ms_footprint_id") WHERE ms_footprint_id IS NOT NULL|}
)
count

View file

@ -9,7 +9,7 @@ defmodule ToweropsWeb.CoverageLive.Index do
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
if connected?(socket), do: Coverages.subscribe_organization(organization.id)
_ = if connected?(socket), do: Coverages.subscribe_organization(organization.id)
{:ok,
socket

View file

@ -24,7 +24,7 @@ defmodule ToweropsWeb.CoverageLive.Map do
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
if connected?(socket), do: Coverages.subscribe_organization(organization.id)
_ = if connected?(socket), do: Coverages.subscribe_organization(organization.id)
coverages = Coverages.list_ready_for_organization(organization.id)

View file

@ -14,7 +14,7 @@ defmodule ToweropsWeb.CoverageLive.Show do
antenna = Antenna.get(coverage.antenna_slug)
eirp = Coverage.eirp_dbm(coverage, (antenna && antenna.gain_dbi) || 0.0)
if connected?(socket), do: Coverages.subscribe(coverage.id)
_ = if connected?(socket), do: Coverages.subscribe(coverage.id)
{:ok,
socket

View file

@ -134,7 +134,10 @@ defmodule Towerops.MixProject do
:redix,
:telemetry,
:ssl,
:public_key
:public_key,
:geo,
:geo_postgis,
:db_connection
],
flags: [:unmatched_returns, :error_handling, :underspecs, :unknown],
ignore_warnings: ".dialyzer_ignore.exs"

View file

@ -0,0 +1,91 @@
defmodule Towerops.Coverages.BuildingTest do
use Towerops.DataCase, async: true
alias Towerops.Coverages.Building
describe "default_height_m/0" do
test "returns the single-storey fallback height" do
assert Building.default_height_m() == 6.0
end
end
describe "rooftop_height_m/1" do
test "returns the recorded height when present and positive" do
assert Building.rooftop_height_m(%Building{height_m: 12.5}) == 12.5
end
test "falls back to the default when the height is missing" do
assert Building.rooftop_height_m(%Building{height_m: nil}) == Building.default_height_m()
end
test "falls back to the default when the height is zero" do
assert Building.rooftop_height_m(%Building{height_m: 0.0}) == Building.default_height_m()
end
test "falls back to the default for negative heights (data corruption guard)" do
assert Building.rooftop_height_m(%Building{height_m: -3.0}) == Building.default_height_m()
end
end
describe "changeset/2" do
@valid_geom %Geo.Polygon{
coordinates: [
[{-97.74, 30.27}, {-97.73, 30.27}, {-97.73, 30.28}, {-97.74, 30.28}, {-97.74, 30.27}]
],
srid: 4326
}
test "accepts a complete polygon row" do
changeset =
Building.changeset(%{
source: "ms_global_ml",
geom: @valid_geom,
height_m: 8.5,
ms_footprint_id: "abc-123"
})
assert changeset.valid?
end
test "requires geom" do
changeset = Building.changeset(%{height_m: 5.0})
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).geom
end
test "rejects negative heights" do
changeset =
Building.changeset(%{
source: "ms_global_ml",
geom: @valid_geom,
height_m: -1.0
})
refute changeset.valid?
assert Enum.any?(errors_on(changeset).height_m, &(&1 =~ "greater than"))
end
test "rejects implausibly tall heights" do
changeset =
Building.changeset(%{
source: "ms_global_ml",
geom: @valid_geom,
height_m: 5_000.0
})
refute changeset.valid?
assert Enum.any?(errors_on(changeset).height_m, &(&1 =~ "less than"))
end
test "defaults derived_height to false" do
changeset =
Building.changeset(%{
source: "ms_global_ml",
geom: @valid_geom
})
assert changeset.valid?
assert get_field(changeset, :derived_height) == false
end
end
end

View file

@ -7,26 +7,128 @@ defmodule Towerops.Coverages.TreeCanopyTest do
on_exit(fn ->
Application.delete_env(:towerops, :tree_canopy_enabled)
Application.delete_env(:towerops, :tree_canopy_url)
Application.delete_env(:towerops, :tree_canopy_provider)
Application.delete_env(:towerops, :tree_canopy_decoder)
Application.delete_env(:towerops, :lidar_runner)
end)
:ok
end
describe "fetch_grid/2" do
test "returns :disabled when the canopy source is turned off" do
test "returns :disabled when the source is turned off" do
Application.put_env(:towerops, :tree_canopy_enabled, false)
assert {:error, :disabled} =
TreeCanopy.fetch_grid({-97.8, 30.2, -97.7, 30.3}, 0.001)
end
test "returns :no_canopy_url_configured for :custom provider with no URL" do
Application.put_env(:towerops, :tree_canopy_enabled, true)
Application.put_env(:towerops, :tree_canopy_provider, :custom)
Application.delete_env(:towerops, :tree_canopy_url)
assert {:error, :no_canopy_url_configured} =
TreeCanopy.fetch_grid({-97.8, 30.2, -97.7, 30.3}, 0.001)
end
end
describe "source_url/0" do
test "defaults to the public LANDFIRE EVH COG" do
assert TreeCanopy.source_url() =~ "landfire.gov"
describe "source_url/1" do
test ":eth provider builds a per-bbox tile URL with N/E quadrant naming" do
Application.put_env(:towerops, :tree_canopy_provider, :eth)
url = TreeCanopy.source_url({-97.8, 30.2, -97.7, 30.3})
assert url =~ "ETH_GlobalCanopyHeight_10m_2020_N30W099_Map.tif"
end
test "respects the runtime override" do
test ":eth provider names southern-hemisphere tiles with S prefix" do
Application.put_env(:towerops, :tree_canopy_provider, :eth)
url = TreeCanopy.source_url({150.0, -33.5, 150.1, -33.4})
assert url =~ "ETH_GlobalCanopyHeight_10m_2020_S36E150_Map.tif"
end
test ":eth provider returns nil for tiles past the polar cap" do
Application.put_env(:towerops, :tree_canopy_provider, :eth)
# 87°N is past the EVH/canopy coverage extent.
assert is_nil(TreeCanopy.source_url({0.0, 87.0, 0.1, 87.1}))
end
test ":custom provider returns the configured URL" do
Application.put_env(:towerops, :tree_canopy_provider, :custom)
Application.put_env(:towerops, :tree_canopy_url, "https://example.com/canopy.tif")
assert TreeCanopy.source_url() == "https://example.com/canopy.tif"
end
end
describe "decode_grid/2 (via fetch_grid with stubbed runner)" do
test ":metres decoder maps NoData (255) to 0.0 and keeps other values" do
Application.put_env(:towerops, :tree_canopy_enabled, true)
Application.put_env(:towerops, :tree_canopy_provider, :eth)
aaigrid = """
ncols 4
nrows 2
xllcorner -98.0
yllcorner 30.0
cellsize 0.5
nodata_value -9999
0 12 25 255
40 100 200 255
"""
Application.put_env(
:towerops,
:lidar_runner,
fn _cmd, _args, _opts -> {aaigrid, 0} end
)
{:ok, grid} = TreeCanopy.fetch_grid({-98.0, 30.0, -96.0, 31.0}, 0.5)
# NoData (255) → 0.0; everything else preserved as direct metres.
assert grid.cells == [[0.0, 12.0, 25.0, 0.0], [40.0, 100.0, 200.0, 0.0]]
assert grid.nodata_value == -9999.0
end
test ":landfire_evh decoder translates EVH codes into metre heights" do
Application.put_env(:towerops, :tree_canopy_enabled, true)
Application.put_env(:towerops, :tree_canopy_provider, :custom)
Application.put_env(:towerops, :tree_canopy_url, "https://example.test/evh.tif")
Application.put_env(:towerops, :tree_canopy_decoder, :landfire_evh)
aaigrid = """
ncols 4
nrows 2
xllcorner -98.0
yllcorner 30.0
cellsize 0.5
nodata_value -9999
120 5 15 250
0 199 11 100
"""
Application.put_env(
:towerops,
:lidar_runner,
fn _cmd, _args, _opts -> {aaigrid, 0} end
)
{:ok, grid} = TreeCanopy.fetch_grid({-98.0, 30.0, -96.0, 31.0}, 0.5)
assert grid.cells == [[20.0, 0.0, 0.5, 0.0], [0.0, 99.0, 0.1, 0.0]]
end
test "surfaces GDAL failures as {:error, _}" do
Application.put_env(:towerops, :tree_canopy_enabled, true)
Application.put_env(:towerops, :tree_canopy_provider, :eth)
Application.put_env(
:towerops,
:lidar_runner,
fn _cmd, _args, _opts -> {"boom", 1} end
)
assert {:error, _} = TreeCanopy.fetch_grid({-98.0, 30.0, -96.0, 31.0}, 0.5)
end
end
end

View file

@ -0,0 +1,146 @@
defmodule Towerops.Workers.MsBuildingsImportWorkerTest do
use Towerops.DataCase, async: false
use Oban.Testing, repo: Towerops.Repo
alias Towerops.Coverages.Building
alias Towerops.Workers.MsBuildingsImportWorker
describe "import_file/2" do
test "imports a polygon feature with height into coverage_buildings" do
path = write_geojsonl([feature(0.0, 0.0, height: 8.5, id: "row-1")])
try do
assert {:ok, %{inserted: 1, skipped: 0}} = MsBuildingsImportWorker.import_file(path)
[row] = Repo.all(Building)
assert row.height_m == 8.5
assert row.ms_footprint_id == "row-1"
assert row.source == "ms_global_ml"
after
File.rm(path)
end
end
test "skips blank lines and malformed JSON without crashing" do
path =
write_lines([
"",
"not json at all",
Jason.encode!(feature(1.0, 1.0, id: "ok-1"))
])
try do
assert {:ok, %{inserted: 1, skipped: 2}} = MsBuildingsImportWorker.import_file(path)
after
File.rm(path)
end
end
test "is idempotent — re-importing the same file inserts no duplicates" do
path = write_geojsonl([feature(2.0, 2.0, id: "dup-1", height: 5.0)])
try do
assert {:ok, %{inserted: 1}} = MsBuildingsImportWorker.import_file(path)
assert {:ok, %{inserted: 1}} = MsBuildingsImportWorker.import_file(path)
# Insert-or-replace via (source, ms_footprint_id) ⇒ one row in DB.
assert Repo.aggregate(Building, :count) == 1
after
File.rm(path)
end
end
test "supports MultiPolygon features by taking the first polygon" do
multi = %{
"type" => "Feature",
"properties" => %{"id" => "mp-1", "height" => 4.0},
"geometry" => %{
"type" => "MultiPolygon",
"coordinates" => [
[[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]]]
]
}
}
path = write_lines([Jason.encode!(multi)])
try do
assert {:ok, %{inserted: 1, skipped: 0}} = MsBuildingsImportWorker.import_file(path)
after
File.rm(path)
end
end
test "hashes polygon when no id is provided so the row is still idempotent" do
feat = feature(3.0, 3.0)
# Strip id so the worker has to derive one.
feat = %{feat | "properties" => Map.delete(feat["properties"], "id")}
path = write_lines([Jason.encode!(feat)])
try do
assert {:ok, %{inserted: 1}} = MsBuildingsImportWorker.import_file(path)
[row] = Repo.all(Building)
# Hash is hex-encoded, 16 bytes long → 32 hex chars.
assert byte_size(row.ms_footprint_id) > 0
after
File.rm(path)
end
end
test "returns {:error, :file_missing} for an absent path" do
assert {:error, :file_missing} = MsBuildingsImportWorker.import_file("/nonexistent/abc.geojsonl")
end
end
describe "perform/1" do
test "returns :ok for a valid file" do
path = write_geojsonl([feature(4.0, 4.0, id: "perform-1")])
try do
assert :ok =
perform_job(MsBuildingsImportWorker, %{path: path, source: "ms_global_ml"})
after
File.rm(path)
end
end
test "returns {:error, :file_missing} when the path is absent" do
assert {:error, :file_missing} =
perform_job(MsBuildingsImportWorker, %{path: "/nope.geojsonl"})
end
end
# -- helpers --
defp feature(lon, lat, opts \\ []) do
coords = [
[lon, lat],
[lon + 0.001, lat],
[lon + 0.001, lat + 0.001],
[lon, lat + 0.001],
[lon, lat]
]
properties =
%{}
|> Map.merge(if opts[:id], do: %{"id" => opts[:id]}, else: %{})
|> Map.merge(if opts[:height], do: %{"height" => opts[:height]}, else: %{})
%{
"type" => "Feature",
"properties" => properties,
"geometry" => %{"type" => "Polygon", "coordinates" => [coords]}
}
end
defp write_geojsonl(features) do
write_lines(Enum.map(features, &Jason.encode!/1))
end
defp write_lines(lines) do
path =
Path.join(System.tmp_dir!(), "ms_buildings_import_test_#{System.unique_integer([:positive])}.geojsonl")
File.write!(path, Enum.join(lines, "\n"))
path
end
end

View file

@ -0,0 +1,11 @@
defmodule ToweropsWeb.GraphQLDocsControllerTest do
use ToweropsWeb.ConnCase
describe "GET /docs/graphql" do
test "renders the docs page for an unauthenticated visitor", %{conn: conn} do
conn = get(conn, ~p"/docs/graphql")
response = response(conn, 200)
assert response =~ "YOUR_API_TOKEN"
end
end
end

View file

@ -0,0 +1,56 @@
defmodule ToweropsWeb.CoverageLive.FormTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.CoveragesFixtures
import Towerops.OrganizationsFixtures
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
org = organization_fixture(user.id)
site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74})
conn = Plug.Conn.put_session(conn, :current_organization_id, org.id)
%{conn: conn, org: org, site: site}
end
describe "new" do
test "renders the new-coverage form", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/coverage/new")
assert html =~ "New Coverage" or html =~ "Coverage"
end
test "validate event re-renders with errors for blank name", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/coverage/new")
html =
view
|> form("form", coverage: %{"name" => ""})
|> render_change()
assert html =~ "Coverage"
end
test "requires authentication" do
conn = build_conn()
assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/coverage/new")
assert path =~ "/users/log-in"
end
end
describe "edit" do
test "renders the edit form populated with the existing coverage", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "EditMe"})
{:ok, _view, html} = live(conn, ~p"/coverage/#{cov.id}/edit")
assert html =~ "EditMe"
end
test "404 for unknown coverage", %{conn: conn} do
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/coverage/#{Ecto.UUID.generate()}/edit")
end
end
end
end

View file

@ -0,0 +1,35 @@
defmodule ToweropsWeb.CoverageLive.IndexTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.CoveragesFixtures
import Towerops.OrganizationsFixtures
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
org = organization_fixture(user.id)
site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74})
conn = Plug.Conn.put_session(conn, :current_organization_id, org.id)
%{conn: conn, org: org, site: site}
end
test "renders empty state when org has no coverages", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/coverage/list")
assert html =~ "Coverage"
end
test "lists coverages for the org", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "Sector North"})
{:ok, _view, html} = live(conn, ~p"/coverage/list")
assert html =~ cov.name
end
test "requires authentication" do
conn = build_conn()
assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/coverage/list")
assert path =~ "/users/log-in"
end
end

View file

@ -0,0 +1,115 @@
defmodule ToweropsWeb.CoverageLive.MapTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.CoveragesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Coverages
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
org = organization_fixture(user.id)
site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74})
conn = Plug.Conn.put_session(conn, :current_organization_id, org.id)
%{conn: conn, org: org, site: site}
end
describe "mount" do
test "shows empty state when no ready coverages exist", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/coverage")
assert html =~ "No ready coverages"
end
test "shows ready coverages in tower toggle list", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "Mt Wilson"})
{:ok, ready} =
Coverages.mark_status(cov, "ready", %{
png_path: "/coverage/#{org.id}/#{cov.id}/rssi.png",
raster_path: "/coverage/#{org.id}/#{cov.id}/rssi.tif",
bbox_min_lat: 30.0,
bbox_min_lon: -98.0,
bbox_max_lat: 30.5,
bbox_max_lon: -97.5
})
{:ok, _view, html} = live(conn, ~p"/coverage")
assert html =~ ready.name
end
test "requires authentication" do
conn = build_conn()
assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/coverage")
assert path =~ "/users/log-in"
end
end
describe "tower toggle events" do
setup %{org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "TowerA"})
{:ok, ready} =
Coverages.mark_status(cov, "ready", %{
png_path: "/coverage/#{org.id}/#{cov.id}/rssi.png",
raster_path: "/coverage/#{org.id}/#{cov.id}/rssi.tif",
bbox_min_lat: 30.0,
bbox_min_lon: -98.0,
bbox_max_lat: 30.5,
bbox_max_lon: -97.5
})
%{coverage: ready}
end
test "toggle_coverage flips one tower on/off", %{conn: conn, coverage: cov} do
{:ok, view, _html} = live(conn, ~p"/coverage")
# First click: disable
render_click(view, "toggle_coverage", %{"id" => cov.id})
# Second click: re-enable
assert render_click(view, "toggle_coverage", %{"id" => cov.id})
end
test "show_all and hide_all bulk toggle", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/coverage")
assert render_click(view, "hide_all", %{})
assert render_click(view, "show_all", %{})
end
test "set_probe_units switches between ft and m", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/coverage")
assert render_click(view, "set_probe_units", %{"units" => "m"})
assert render_click(view, "set_probe_units", %{"units" => "ft"})
end
test "close_probe is a no-op when no probe is open", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/coverage")
assert render_click(view, "close_probe", %{})
end
end
describe "format helpers" do
alias ToweropsWeb.CoverageLive.Map, as: M
test "fmt_distance converts to mi or km" do
assert M.fmt_distance(1609.344, "ft") == "1.00 mi"
assert M.fmt_distance(1000, "m") == "1.00 km"
assert M.fmt_distance(nil, "ft") == ""
end
test "fmt_rssi formats values" do
assert M.fmt_rssi(:no_coverage) == "N/A"
assert M.fmt_rssi(nil) == ""
assert M.fmt_rssi(-65.0) =~ "dBm"
end
test "coverages_payload shapes records for the JS hook" do
payload = M.coverages_payload([])
assert payload == []
end
end
end

View file

@ -0,0 +1,142 @@
defmodule ToweropsWeb.CoverageLive.ShowTest do
use ToweropsWeb.ConnCase
import Phoenix.LiveViewTest
import Towerops.CoveragesFixtures
import Towerops.OrganizationsFixtures
alias Towerops.Coverages
setup :register_and_log_in_user
setup %{conn: conn, user: user} do
org = organization_fixture(user.id)
site = site_fixture(org.id, %{latitude: 30.27, longitude: -97.74})
conn = Plug.Conn.put_session(conn, :current_organization_id, org.id)
%{conn: conn, org: org, site: site}
end
describe "mount" do
test "renders coverage details for a draft coverage", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "Hilltop Sector"})
{:ok, _view, html} = live(conn, ~p"/coverage/#{cov.id}")
assert html =~ "Hilltop Sector"
assert html =~ "No heatmap available yet"
end
test "renders the parameters drawer when coverage is ready", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "ReadyOne"})
{:ok, _ready} =
Coverages.mark_status(cov, "ready", %{
png_path: "/coverage/#{org.id}/#{cov.id}/rssi.png",
raster_path: "/coverage/#{org.id}/#{cov.id}/rssi.tif",
bbox_min_lat: 30.0,
bbox_min_lon: -98.0,
bbox_max_lat: 30.5,
bbox_max_lon: -97.5
})
{:ok, _view, html} = live(conn, ~p"/coverage/#{cov.id}")
assert html =~ "Parameters"
end
test "shows queued banner for queued coverage", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id)
{:ok, queued} = Coverages.mark_status(cov, "queued", %{})
{:ok, _view, html} = live(conn, ~p"/coverage/#{queued.id}")
assert html =~ "queued"
end
test "shows failed banner with error message", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id)
{:ok, failed} = Coverages.mark_status(cov, "failed", %{error_message: "test failure"})
{:ok, _view, html} = live(conn, ~p"/coverage/#{failed.id}")
assert html =~ "test failure"
end
test "404 for unknown coverage", %{conn: conn} do
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/coverage/#{Ecto.UUID.generate()}")
end
end
test "requires authentication" do
conn = build_conn()
assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/coverage/#{Ecto.UUID.generate()}")
assert path =~ "/users/log-in"
end
end
describe "events" do
test "toggle_params hides and re-shows the parameters drawer", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id)
{:ok, view, html} = live(conn, ~p"/coverage/#{cov.id}")
assert html =~ "Hide details"
html = render_click(view, "toggle_params", %{})
assert html =~ "Details"
refute html =~ "Hide details"
end
test "set_probe_units switches between ft and m", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id)
{:ok, view, _html} = live(conn, ~p"/coverage/#{cov.id}")
assert render_click(view, "set_probe_units", %{"units" => "m"})
assert render_click(view, "set_probe_units", %{"units" => "ft"})
end
test "delete removes the coverage and redirects", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id, %{name: "DoomedCoverage"})
{:ok, view, _html} = live(conn, ~p"/coverage/#{cov.id}")
result = render_click(view, "delete", %{})
assert {:error, {:live_redirect, %{to: "/coverage"}}} = result
assert nil == Coverages.get_coverage(org.id, cov.id)
end
test "recompute queues compute and refreshes status", %{conn: conn, org: org, site: site} do
cov = coverage_fixture(org.id, site.id)
{:ok, view, _html} = live(conn, ~p"/coverage/#{cov.id}")
html = render_click(view, "recompute", %{})
assert html =~ "queued"
end
end
describe "format helpers" do
alias ToweropsWeb.CoverageLive.Show
test "format_ft converts metres to feet" do
assert Show.format_ft(30.0) == "98 ft"
assert Show.format_ft(nil) == ""
end
test "format_mi converts metres to miles" do
assert Show.format_mi(1609.344) == "1.0 mi"
assert Show.format_mi(nil) == ""
end
test "format_ghz converts MHz to GHz" do
assert Show.format_ghz(5800) == "5.8 GHz"
assert Show.format_ghz(nil) == ""
end
test "status_badge_class maps known statuses" do
assert Show.status_badge_class("ready") =~ "bg-green"
assert Show.status_badge_class("failed") =~ "bg-red"
assert Show.status_badge_class("computing") =~ "bg-yellow"
assert Show.status_badge_class("queued") =~ "bg-blue"
assert Show.status_badge_class("draft") =~ "bg-gray"
assert Show.status_badge_class("anything-else") =~ "bg-gray"
end
end
end