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:
parent
4e391b3ecf
commit
0e2cc6b7ce
24 changed files with 905 additions and 68 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
mix.exs
5
mix.exs
|
|
@ -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"
|
||||
|
|
|
|||
91
test/towerops/coverages/building_test.exs
Normal file
91
test/towerops/coverages/building_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
146
test/towerops/workers/ms_buildings_import_worker_test.exs
Normal file
146
test/towerops/workers/ms_buildings_import_worker_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
56
test/towerops_web/live/coverage_live/form_test.exs
Normal file
56
test/towerops_web/live/coverage_live/form_test.exs
Normal 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
|
||||
35
test/towerops_web/live/coverage_live/index_test.exs
Normal file
35
test/towerops_web/live/coverage_live/index_test.exs
Normal 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
|
||||
115
test/towerops_web/live/coverage_live/map_test.exs
Normal file
115
test/towerops_web/live/coverage_live/map_test.exs
Normal 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
|
||||
142
test/towerops_web/live/coverage_live/show_test.exs
Normal file
142
test/towerops_web/live/coverage_live/show_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue