From 0e2cc6b7ce1748a9fe898f2e41d74f0a6efbca80 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 7 May 2026 09:11:17 -0500 Subject: [PATCH] chore(coverage,types): clean dialyzer + new tests + ETH canopy provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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%. --- lib/towerops/coverages.ex | 11 +- lib/towerops/coverages/antenna.ex | 8 +- lib/towerops/coverages/building.ex | 2 +- lib/towerops/coverages/profile.ex | 1 - lib/towerops/coverages/raster.ex | 2 +- lib/towerops/coverages/tree_canopy.ex | 176 ++++++++++++++---- lib/towerops/lidar.ex | 6 +- lib/towerops/lidar/reader.ex | 3 +- lib/towerops/lidar/tile.ex | 18 ++ lib/towerops/topology.ex | 2 +- lib/towerops/workers/coverage_worker.ex | 13 +- .../workers/ms_buildings_import_worker.ex | 12 +- lib/towerops_web/live/coverage_live/index.ex | 2 +- lib/towerops_web/live/coverage_live/map.ex | 2 +- lib/towerops_web/live/coverage_live/show.ex | 2 +- mix.exs | 5 +- test/towerops/coverages/building_test.exs | 91 +++++++++ test/towerops/coverages/tree_canopy_test.exs | 112 ++++++++++- .../ms_buildings_import_worker_test.exs | 146 +++++++++++++++ .../graphql_docs_controller_test.exs | 11 ++ .../live/coverage_live/form_test.exs | 56 ++++++ .../live/coverage_live/index_test.exs | 35 ++++ .../live/coverage_live/map_test.exs | 115 ++++++++++++ .../live/coverage_live/show_test.exs | 142 ++++++++++++++ 24 files changed, 905 insertions(+), 68 deletions(-) create mode 100644 test/towerops/coverages/building_test.exs create mode 100644 test/towerops/workers/ms_buildings_import_worker_test.exs create mode 100644 test/towerops_web/controllers/graphql_docs_controller_test.exs create mode 100644 test/towerops_web/live/coverage_live/form_test.exs create mode 100644 test/towerops_web/live/coverage_live/index_test.exs create mode 100644 test/towerops_web/live/coverage_live/map_test.exs create mode 100644 test/towerops_web/live/coverage_live/show_test.exs diff --git a/lib/towerops/coverages.ex b/lib/towerops/coverages.ex index b266fb1e..e48fc6c1 100644 --- a/lib/towerops/coverages.ex +++ b/lib/towerops/coverages.ex @@ -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 diff --git a/lib/towerops/coverages/antenna.ex b/lib/towerops/coverages/antenna.ex index 0390e6f9..c4ac4dca 100644 --- a/lib/towerops/coverages/antenna.ex +++ b/lib/towerops/coverages/antenna.ex @@ -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() } diff --git a/lib/towerops/coverages/building.ex b/lib/towerops/coverages/building.ex index f5504d8f..e923f662 100644 --- a/lib/towerops/coverages/building.ex +++ b/lib/towerops/coverages/building.ex @@ -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, diff --git a/lib/towerops/coverages/profile.ex b/lib/towerops/coverages/profile.ex index 66121db7..a1dd2e4b 100644 --- a/lib/towerops/coverages/profile.ex +++ b/lib/towerops/coverages/profile.ex @@ -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, diff --git a/lib/towerops/coverages/raster.ex b/lib/towerops/coverages/raster.ex index 29e95ea0..381bca65 100644 --- a/lib/towerops/coverages/raster.ex +++ b/lib/towerops/coverages/raster.ex @@ -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 diff --git a/lib/towerops/coverages/tree_canopy.ex b/lib/towerops/coverages/tree_canopy.ex index b97ae855..2243f08e 100644 --- a/lib/towerops/coverages/tree_canopy.ex +++ b/lib/towerops/coverages/tree_canopy.ex @@ -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 diff --git a/lib/towerops/lidar.ex b/lib/towerops/lidar.ex index 2725eeb1..f2c2d4ed 100644 --- a/lib/towerops/lidar.ex +++ b/lib/towerops/lidar.ex @@ -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) diff --git a/lib/towerops/lidar/reader.ex b/lib/towerops/lidar/reader.ex index 9f464f11..a16fa0ad 100644 --- a/lib/towerops/lidar/reader.ex +++ b/lib/towerops/lidar/reader.ex @@ -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) diff --git a/lib/towerops/lidar/tile.ex b/lib/towerops/lidar/tile.ex index 4f7dde90..8720e4aa 100644 --- a/lib/towerops/lidar/tile.ex +++ b/lib/towerops/lidar/tile.ex @@ -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 diff --git a/lib/towerops/topology.ex b/lib/towerops/topology.ex index 73644c82..91801c96 100644 --- a/lib/towerops/topology.ex +++ b/lib/towerops/topology.ex @@ -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 diff --git a/lib/towerops/workers/coverage_worker.ex b/lib/towerops/workers/coverage_worker.ex index 387e9a4d..8c47825c 100644 --- a/lib/towerops/workers/coverage_worker.ex +++ b/lib/towerops/workers/coverage_worker.ex @@ -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), diff --git a/lib/towerops/workers/ms_buildings_import_worker.ex b/lib/towerops/workers/ms_buildings_import_worker.ex index 58d92ad5..2c0c036a 100644 --- a/lib/towerops/workers/ms_buildings_import_worker.ex +++ b/lib/towerops/workers/ms_buildings_import_worker.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/index.ex b/lib/towerops_web/live/coverage_live/index.ex index fd642f55..f459b567 100644 --- a/lib/towerops_web/live/coverage_live/index.ex +++ b/lib/towerops_web/live/coverage_live/index.ex @@ -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 diff --git a/lib/towerops_web/live/coverage_live/map.ex b/lib/towerops_web/live/coverage_live/map.ex index d6b8e1e0..71adf7c7 100644 --- a/lib/towerops_web/live/coverage_live/map.ex +++ b/lib/towerops_web/live/coverage_live/map.ex @@ -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) diff --git a/lib/towerops_web/live/coverage_live/show.ex b/lib/towerops_web/live/coverage_live/show.ex index 11327470..beeaf02d 100644 --- a/lib/towerops_web/live/coverage_live/show.ex +++ b/lib/towerops_web/live/coverage_live/show.ex @@ -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 diff --git a/mix.exs b/mix.exs index 42d2e20c..cd3cc168 100644 --- a/mix.exs +++ b/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" diff --git a/test/towerops/coverages/building_test.exs b/test/towerops/coverages/building_test.exs new file mode 100644 index 00000000..18de4cf2 --- /dev/null +++ b/test/towerops/coverages/building_test.exs @@ -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 diff --git a/test/towerops/coverages/tree_canopy_test.exs b/test/towerops/coverages/tree_canopy_test.exs index 4376f6c7..8e59e16e 100644 --- a/test/towerops/coverages/tree_canopy_test.exs +++ b/test/towerops/coverages/tree_canopy_test.exs @@ -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 diff --git a/test/towerops/workers/ms_buildings_import_worker_test.exs b/test/towerops/workers/ms_buildings_import_worker_test.exs new file mode 100644 index 00000000..8c1152ca --- /dev/null +++ b/test/towerops/workers/ms_buildings_import_worker_test.exs @@ -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 diff --git a/test/towerops_web/controllers/graphql_docs_controller_test.exs b/test/towerops_web/controllers/graphql_docs_controller_test.exs new file mode 100644 index 00000000..4d4b3794 --- /dev/null +++ b/test/towerops_web/controllers/graphql_docs_controller_test.exs @@ -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 diff --git a/test/towerops_web/live/coverage_live/form_test.exs b/test/towerops_web/live/coverage_live/form_test.exs new file mode 100644 index 00000000..0e9494d5 --- /dev/null +++ b/test/towerops_web/live/coverage_live/form_test.exs @@ -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 diff --git a/test/towerops_web/live/coverage_live/index_test.exs b/test/towerops_web/live/coverage_live/index_test.exs new file mode 100644 index 00000000..77860aa6 --- /dev/null +++ b/test/towerops_web/live/coverage_live/index_test.exs @@ -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 diff --git a/test/towerops_web/live/coverage_live/map_test.exs b/test/towerops_web/live/coverage_live/map_test.exs new file mode 100644 index 00000000..d03f82e9 --- /dev/null +++ b/test/towerops_web/live/coverage_live/map_test.exs @@ -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 diff --git a/test/towerops_web/live/coverage_live/show_test.exs b/test/towerops_web/live/coverage_live/show_test.exs new file mode 100644 index 00000000..a9314ed7 --- /dev/null +++ b/test/towerops_web/live/coverage_live/show_test.exs @@ -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