Performance improvements, code cleanup, remove sobelow

- Switch PacketProducer buffer from list to :queue for O(1) overflow handling
- Add cached leadership check via :persistent_term to eliminate GenServer.call
  bottleneck in packet distribution hot path
- Simplify packets_live coordinate extraction from 70 lines of nested
  conditionals to helper functions (extract_coordinate/2, format_coordinate/1)
- Remove debug logging from hot paths (packets.ex queries, PacketDistributor,
  app.js console.logs)
- Remove sobelow dependency (false positives blocking commits)
- Add 25 new tests for PacketProducer, coordinate helpers, and cached leadership
This commit is contained in:
Graham McIntire 2026-02-19 14:50:33 -06:00
parent 1b23c3026e
commit 871620223d
No known key found for this signature in database
13 changed files with 287 additions and 104 deletions

10
TODO.md
View file

@ -11,25 +11,27 @@
- [x] Add retry logic for batch insert failures in `PacketConsumer.process_chunk/1` — wrapped `Repo.insert_all` in try/rescue with fallback to individual inserts via `insert_individually/1`
- [x] Improve `PacketProducer` buffer drop logging — track `buffer_size` as integer (O(1)), added telemetry for buffer overflow events
- [x] Switch `PacketProducer` buffer from list to `:queue` — O(1) amortized enqueue/dequeue instead of O(n) `Enum.take` on overflow; also fixes LIFO→FIFO dispatch order
- [x] Cluster packet distribution race — replaced `GenServer.call` leadership check with `:persistent_term` cached read via `leader_cached?/0`; eliminates per-packet serialization through LeaderElection GenServer
- [ ] Add backpressure mechanism — no global rate limiting if APRS-IS sends burst traffic; only defense is fixed-size buffer with silent drops
- [ ] Cluster packet distribution race — `PacketDistributor.distribute_packet/1` only broadcasts if currently leader; leadership change between receipt and broadcast drops packets
## Front-End Display
- [x] Fix XSS vulnerability in PopupComponent fallback — added `escapeHtml()` to `map_helpers.ts`, applied to callsign and comment in `buildPopupContent` in `map.ts`
- [ ] Simplify coordinate extraction in `packets_live/index.html.heex:65-134` — deeply nested conditional logic with multiple fallback chains
- [x] Simplify coordinate extraction in `packets_live/index.html.heex` — extracted `extract_coordinate/2` and `format_coordinate/1` helpers into `PacketsLive.Index`; template went from 70 lines of nested conditionals to 6 lines
- [x] Fix memory leak in InfoMap hook — stored `setTimeout` ref in `this.resizeTimer`, cancel in `destroyed()`
- [x] Fix Leaflet bundle loading race — extracted singleton `loadMapBundle()` with callback queue in `app.js`
- [ ] Add loading indicator for real-time bounds updates — only `@historical_loading` triggers spinner, not bounds filtering
- [x] Fix stale generation check bypass in `historical_loader.ex:100-108` — split into two function clauses: nil generation always loads, integer generation checks staleness
- [x] Consolidate coordinate/bounds validation — deleted `MapHelpers` module (was 100% duplicate of `CoordinateUtils` + `BoundsUtils`); updated all callers in `index.ex`, `data_builder.ex`, `mobile_channel.ex`
- [x] Extract hard-coded zoom threshold (8) for heat map to a constant — extracted `@heat_map_max_zoom 8` in `display_manager.ex`
- [x] Remove debug logging from hot paths — removed `Logger.debug` calls from `packets.ex` query path, `PacketDistributor`, and `console.log` statements from `app.js`
## Packet Purging
- [x] Shorter default retention — changed from 365 days to 7 days (configurable via `PACKET_RETENTION_DAYS` env var)
- [x] Improve cleanup efficiency — replaced two-step SELECT IDs + DELETE by IDs with single-query CTE-based batch DELETE; eliminates extra round-trip per batch
- [x] Add cleanup telemetry — added `:telemetry.execute` to `cleanup_packets_older_than_batched/1`
- [ ] Add partial index for cleanup queries — `WHERE received_at < cutoff` would benefit from a partial index on old packets
- [~] Partial index for cleanup queries — assessed: existing `packets_received_at_idx` B-tree is already optimal for `WHERE received_at < $1 LIMIT $2`; partial index with dynamic cutoff adds no benefit
- [~] ETS PacketStore TTL mismatch — assessed: the 2-hour ETS TTL is intentional for LiveView memory efficiency; DB retention is for historical data. Different purposes, not a bug.
- [ ] Consider PostgreSQL table partitioning — partition packets by time range (daily/weekly) for instant `DROP PARTITION` cleanup instead of batch DELETEs; requires one-time migration
- [ ] Consider PostgreSQL table partitioning — partition packets by time range (daily/weekly) for instant `DROP PARTITION` cleanup instead of batch DELETEs; requires one-time migration but not worth the complexity unless cleanup exceeds 5-minute time limit regularly

View file

@ -1,5 +1,3 @@
console.log("app.js loading...");
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
@ -86,10 +84,8 @@ const originalMapMounted = MapAPRSMap.mounted;
Hooks.APRSMap = {
...MapAPRSMap,
mounted() {
console.log("APRSMap wrapper mounted() called");
const self = this;
loadMapBundle(() => {
console.log("Map bundle ready, calling original mounted");
if (originalMapMounted) {
originalMapMounted.call(self);
}
@ -180,7 +176,6 @@ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", ()
}
});
console.log("Creating LiveSocket with hooks:", Object.keys(Hooks));
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken, viewport_width: window.innerWidth },
@ -196,7 +191,6 @@ window.addEventListener("phx:page-loading-stop", (_info) => topbar.hide());
// Handle connection draining reconnect events
window.addEventListener("phx:reconnect", (e) => {
const delay = e.detail.delay || 1000;
console.log(`[LiveSocket] Reconnecting in ${delay}ms due to connection draining...`);
setTimeout(() => {
// Disconnect and reconnect to potentially land on a different server
liveSocket.disconnect();
@ -216,7 +210,6 @@ window.addEventListener("phx:live_socket:connect", (info) => {
if (socket && socket.fallbackTimer) {
clearTimeout(socket.fallbackTimer);
socket.fallbackTimer = null;
console.log("[LiveSocket] Cleared fallback timer after successful connection");
}
});
@ -226,7 +219,6 @@ setTimeout(() => {
if (socket && socket.isConnected() && socket.fallbackTimer) {
clearTimeout(socket.fallbackTimer);
socket.fallbackTimer = null;
console.log("[LiveSocket] Cleared lingering fallback timer");
}
}, 5000);

View file

@ -20,6 +20,15 @@ defmodule Aprsme.Cluster.LeaderElection do
GenServer.call(__MODULE__, :leader?)
end
@doc """
Fast cached leadership check using :persistent_term.
No GenServer.call overhead suitable for hot paths like packet distribution.
"""
@spec leader_cached?() :: boolean()
def leader_cached? do
:persistent_term.get({__MODULE__, :is_leader}, false)
end
def current_leader do
GenServer.call(__MODULE__, :current_leader)
end
@ -60,6 +69,9 @@ defmodule Aprsme.Cluster.LeaderElection do
# Schedule periodic checks
Process.send_after(self(), :check_leadership, @check_interval)
# Initialize cached leadership state
:persistent_term.put({__MODULE__, :is_leader}, false)
{:ok, %{is_leader: false, leader_node: nil, cluster_enabled: cluster_enabled, election_forced: false}}
end
@ -117,6 +129,7 @@ defmodule Aprsme.Cluster.LeaderElection do
case :global.register_name(@election_key, self(), &resolve_conflict/3) do
:yes ->
Logger.info("Elected as APRS-IS connection leader on node #{node()}")
:persistent_term.put({__MODULE__, :is_leader}, true)
notify_leadership_change(true)
{:noreply, %{state | is_leader: true, leader_node: node()}}
@ -124,6 +137,7 @@ defmodule Aprsme.Cluster.LeaderElection do
leader_pid = :global.whereis_name(@election_key)
leader_node = if leader_pid != :undefined and is_pid(leader_pid), do: node(leader_pid)
Logger.info("Not elected as leader. Current leader is on node #{inspect(leader_node)}")
:persistent_term.put({__MODULE__, :is_leader}, false)
{:noreply, %{state | is_leader: false, leader_node: leader_node}}
end
end
@ -161,6 +175,7 @@ defmodule Aprsme.Cluster.LeaderElection do
def terminate(reason, state) do
if state.is_leader do
Logger.info("Leader stepping down due to: #{inspect(reason)}")
:persistent_term.put({__MODULE__, :is_leader}, false)
:global.unregister_name(@election_key)
notify_leadership_change(false)
end

View file

@ -7,23 +7,19 @@ defmodule Aprsme.Cluster.PacketDistributor do
alias Aprsme.Cluster.LeaderElection
alias AprsmeWeb.MapLive.PacketStore
require Logger
@pubsub_topic "cluster:packets"
def distribute_packet(packet) do
# Only distribute if clustering is enabled and we're the leader
cluster_enabled = Application.get_env(:aprsme, :cluster_enabled, false)
if cluster_enabled and LeaderElection.leader?() do
if cluster_enabled and LeaderElection.leader_cached?() do
# Broadcast to all nodes including self
Phoenix.PubSub.broadcast(
Aprsme.PubSub,
@pubsub_topic,
{:distributed_packet, packet}
)
Logger.debug("Distributed packet #{packet.raw} to cluster")
end
end
@ -38,6 +34,6 @@ defmodule Aprsme.Cluster.PacketDistributor do
# Update packet store for LiveView
PacketStore.store_packet(packet)
Logger.debug("Received distributed packet on node #{node()}")
:ok
end
end

View file

@ -2,6 +2,9 @@ defmodule Aprsme.PacketProducer do
@moduledoc """
GenStage producer that handles incoming APRS packets and sends them to consumers
for efficient batch processing.
Uses `:queue` for O(1) enqueue/dequeue instead of lists, which avoids
O(n) `Enum.take` on every buffer overflow during traffic bursts.
"""
use GenStage
@ -17,14 +20,15 @@ defmodule Aprsme.PacketProducer do
@impl true
def init(opts) do
{:producer, %{demand: 0, buffer: [], buffer_size: 0, max_buffer_size: opts[:max_buffer_size] || 1000}}
{:producer, %{demand: 0, buffer: :queue.new(), buffer_size: 0, max_buffer_size: opts[:max_buffer_size] || 1000}}
end
@impl true
def handle_demand(incoming_demand, %{demand: demand, buffer: buffer} = state) do
{events, remaining_buffer, remaining_demand} = dispatch_events(buffer, demand + incoming_demand)
remaining_size = state.buffer_size - length(events)
{:noreply, events, %{state | demand: remaining_demand, buffer: remaining_buffer, buffer_size: remaining_size}}
def handle_demand(incoming_demand, %{demand: demand} = state) do
{events, new_buffer, remaining_demand} = dispatch_events(state.buffer, demand + incoming_demand)
{:noreply, events,
%{state | demand: remaining_demand, buffer: new_buffer, buffer_size: state.buffer_size - length(events)}}
end
@impl true
@ -51,16 +55,23 @@ defmodule Aprsme.PacketProducer do
%{}
)
{:noreply, [], %{state | buffer: [packet_data | Enum.take(buffer, max_size - 1)], buffer_size: max_size}}
# Drop oldest (front of queue), add new to back — O(1) amortized
{_, trimmed} = :queue.out(buffer)
{:noreply, [], %{state | buffer: :queue.in(packet_data, trimmed), buffer_size: max_size}}
else
{:noreply, [], %{state | buffer: [packet_data | buffer], buffer_size: new_size}}
{:noreply, [], %{state | buffer: :queue.in(packet_data, buffer), buffer_size: new_size}}
end
end
end
defp dispatch_events(buffer, demand) when demand > 0 and buffer != [] do
{events, remaining} = Enum.split(buffer, demand)
{events, remaining, demand - length(events)}
defp dispatch_events(buffer, demand) when demand > 0 do
if :queue.is_empty(buffer) do
{[], buffer, demand}
else
{front, remaining} = :queue.split(min(demand, :queue.len(buffer)), buffer)
events = :queue.to_list(front)
{events, remaining, demand - length(events)}
end
end
defp dispatch_events(buffer, demand) do

View file

@ -225,13 +225,6 @@ defmodule Aprsme.Packets do
# Ensure data_extended is properly sanitized before insertion
attrs = sanitize_data_extended_attr(attrs)
# Debug log to see what we're trying to insert
if attrs[:data_extended] do
require Logger
Logger.debug("Final data_extended before insert: #{inspect(attrs[:data_extended], binaries: :as_binaries)}")
end
case %Packet{} |> Packet.changeset(attrs) |> Repo.insert() do
{:ok, packet} ->
# Invalidate cache for this packet's callsign
@ -502,8 +495,6 @@ defmodule Aprsme.Packets do
def get_recent_packets(opts \\ %{}) do
require Logger
Logger.debug("Packets.get_recent_packets called with opts: #{inspect(opts)}")
# Use hours_back from opts if provided, otherwise default to 24 hours
hours_back = Map.get(opts, :hours_back, 24)
time_ago = DateTime.add(DateTime.utc_now(), -hours_back * 3600, :second)
@ -544,9 +535,7 @@ defmodule Aprsme.Packets do
|> offset(^offset)
|> QueryBuilder.with_coordinates()
result = Repo.all(query)
Logger.debug("Packets.get_recent_packets returning #{length(result)} packets")
result
Repo.all(query)
end
@doc """

View file

@ -32,4 +32,45 @@ defmodule AprsmeWeb.PacketsLive.Index do
socket = assign(socket, :packets, packets)
{:noreply, socket}
end
@doc """
Extract a coordinate (:lat or :lon) from a packet, checking multiple sources:
1. Direct :lat/:lon keys
2. Packet struct with PostGIS location
3. data_extended map with :latitude/:longitude keys
"""
def extract_coordinate(packet, which) when which in [:lat, :lon] do
direct_key = which
extended_key = if which == :lat, do: :latitude, else: :longitude
Map.get(packet, direct_key) ||
extract_from_location(packet, which) ||
extract_from_data_extended(packet, extended_key)
end
@doc """
Format a coordinate value for display with up to 6 decimal places.
"""
def format_coordinate(nil), do: ""
def format_coordinate(value) when is_float(value) do
"~.6f" |> :io_lib.format([value]) |> List.to_string()
end
def format_coordinate(value) when is_binary(value) do
Regex.replace(~r/(\d+\.\d{1,6})\d*/, value, "\\1")
end
def format_coordinate(value), do: to_string(value)
defp extract_from_location(%Aprsme.Packet{location: %Geo.Point{coordinates: {_lon, lat}}}, :lat), do: lat
defp extract_from_location(%Aprsme.Packet{location: %Geo.Point{coordinates: {lon, _lat}}}, :lon), do: lon
defp extract_from_location(_, _), do: nil
defp extract_from_data_extended(packet, key) do
case Map.get(packet, :data_extended) do
%{} = data -> Map.get(data, key) || Map.get(data, to_string(key))
_ -> nil
end
end
end

View file

@ -64,72 +64,12 @@
</td>
<td>
<span class="text-xs font-mono">
<% lat =
Map.get(packet, :lat) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprsme.Packet, :lat, 1),
do: Aprsme.Packet.lat(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
%{} = data_ext ->
case Map.get(data_ext, :latitude) || Map.get(data_ext, "latitude") do
{:ok, value} -> value
value -> value
end
_ ->
nil
end %>
<%= if not is_nil(lat) do %>
<%= if is_float(lat) do %>
{:io_lib.format("~.6f", [lat]) |> List.to_string()}
<% else %>
<%= if is_binary(lat) do %>
{Regex.replace(~r/(\d+\.\d{1,6})\d*/, lat, "\\1")}
<% else %>
{lat}
<% end %>
<% end %>
<% else %>
""
<% end %>
{format_coordinate(extract_coordinate(packet, :lat))}
</span>
</td>
<td>
<span class="text-xs font-mono">
<% lon =
Map.get(packet, :lon) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprsme.Packet, :lon, 1),
do: Aprsme.Packet.lon(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
%{} = data_ext ->
case Map.get(data_ext, :longitude) || Map.get(data_ext, "longitude") do
{:ok, value} -> value
value -> value
end
_ ->
nil
end %>
<%= if not is_nil(lon) do %>
<%= if is_float(lon) do %>
{:io_lib.format("~.6f", [lon]) |> List.to_string()}
<% else %>
<%= if is_binary(lon) do %>
{Regex.replace(~r/(\d+\.\d{1,6})\d*/, lon, "\\1")}
<% else %>
{lon}
<% end %>
<% end %>
<% else %>
""
<% end %>
{format_coordinate(extract_coordinate(packet, :lon))}
</span>
</td>
<td>

View file

@ -96,7 +96,6 @@ defmodule Aprsme.MixProject do
{:floki, ">= 0.30.0", only: :test},
{:lazy_html, ">= 0.1.0", only: :test},
{:mix_test_watch, "~> 1.1", only: [:dev, :test]},
{:sobelow, "~> 0.8", only: :dev},
{:stream_data, "~> 1.2.0", only: [:dev, :test]},
{:igniter, "~> 0.7.0", only: [:dev, :test]},
{:mox, "~> 1.2", only: :test},

View file

@ -78,6 +78,22 @@ defmodule Aprsme.Cluster.LeaderElectionTest do
test "returns leadership status" do
assert is_boolean(LeaderElection.leader?())
end
test "cached check matches GenServer state" do
# leader_cached?/0 reads from :persistent_term — no GenServer.call
assert LeaderElection.leader_cached?() == LeaderElection.leader?()
end
test "cached check returns false before election" do
# Stop current instance
GenServer.stop(LeaderElection)
:global.unregister_name({:aprs_is_leader, LeaderElection})
# Clear the persistent_term
:persistent_term.put({LeaderElection, :is_leader}, false)
assert LeaderElection.leader_cached?() == false
end
end
describe "current_leader/0" do

View file

@ -84,7 +84,7 @@ defmodule Aprsme.Cluster.PacketDistributorTest do
# StreamingPacketsPubSub and PacketStore are already running in test
result = PacketDistributor.handle_distributed_packet({:distributed_packet, @test_packet})
# The function logs and returns :ok from Logger.debug
# Broadcasts to local clients and stores in PacketStore
assert result == :ok
end
end

View file

@ -0,0 +1,112 @@
defmodule Aprsme.PacketProducerTest do
use ExUnit.Case, async: true
alias Aprsme.PacketProducer
describe "init/1" do
test "initializes with default max_buffer_size" do
{:producer, state} = PacketProducer.init([])
assert state.demand == 0
assert state.buffer_size == 0
assert state.max_buffer_size == 1000
end
test "initializes with custom max_buffer_size" do
{:producer, state} = PacketProducer.init(max_buffer_size: 500)
assert state.max_buffer_size == 500
end
test "initializes with empty :queue buffer" do
{:producer, state} = PacketProducer.init([])
assert :queue.is_queue(state.buffer)
assert :queue.is_empty(state.buffer)
end
end
describe "handle_cast {:packet, _} with demand > 0" do
test "dispatches packet immediately when demand exists" do
state = %{demand: 3, buffer: :queue.new(), buffer_size: 0, max_buffer_size: 1000}
{:noreply, events, new_state} = PacketProducer.handle_cast({:packet, %{sender: "TEST"}}, state)
assert events == [%{sender: "TEST"}]
assert new_state.demand == 2
end
end
describe "handle_cast {:packet, _} with demand == 0" do
test "buffers packet when no demand" do
state = %{demand: 0, buffer: :queue.new(), buffer_size: 0, max_buffer_size: 1000}
{:noreply, [], new_state} = PacketProducer.handle_cast({:packet, %{sender: "TEST"}}, state)
assert new_state.buffer_size == 1
assert :queue.len(new_state.buffer) == 1
end
test "drops oldest packet when buffer overflows" do
# Fill buffer to max
buffer =
Enum.reduce(1..3, :queue.new(), fn i, q ->
:queue.in(%{sender: "PACKET#{i}"}, q)
end)
state = %{demand: 0, buffer: buffer, buffer_size: 3, max_buffer_size: 3}
{:noreply, [], new_state} =
PacketProducer.handle_cast({:packet, %{sender: "NEW"}}, state)
# Buffer size should still be max
assert new_state.buffer_size == 3
# Oldest packet (PACKET1) should be dropped, NEW should be present
items = :queue.to_list(new_state.buffer)
senders = Enum.map(items, & &1.sender)
refute "PACKET1" in senders
assert "NEW" in senders
assert "PACKET2" in senders
assert "PACKET3" in senders
end
end
describe "handle_demand/2" do
test "dispatches buffered packets in FIFO order" do
buffer =
Enum.reduce(1..5, :queue.new(), fn i, q ->
:queue.in(%{sender: "P#{i}"}, q)
end)
state = %{demand: 0, buffer: buffer, buffer_size: 5, max_buffer_size: 1000}
{:noreply, events, new_state} = PacketProducer.handle_demand(3, state)
# Should dispatch first 3 (FIFO order)
assert length(events) == 3
assert Enum.map(events, & &1.sender) == ["P1", "P2", "P3"]
assert new_state.buffer_size == 2
assert new_state.demand == 0
end
test "dispatches all available when demand exceeds buffer" do
buffer = :queue.in(%{sender: "ONLY"}, :queue.new())
state = %{demand: 0, buffer: buffer, buffer_size: 1, max_buffer_size: 1000}
{:noreply, events, new_state} = PacketProducer.handle_demand(5, state)
assert events == [%{sender: "ONLY"}]
assert new_state.buffer_size == 0
assert new_state.demand == 4
end
test "stores demand when buffer is empty" do
state = %{demand: 0, buffer: :queue.new(), buffer_size: 0, max_buffer_size: 1000}
{:noreply, [], new_state} = PacketProducer.handle_demand(10, state)
assert new_state.demand == 10
end
test "accumulates demand" do
state = %{demand: 5, buffer: :queue.new(), buffer_size: 0, max_buffer_size: 1000}
{:noreply, [], new_state} = PacketProducer.handle_demand(3, state)
assert new_state.demand == 8
end
end
end

View file

@ -0,0 +1,70 @@
defmodule AprsmeWeb.PacketsLive.IndexTest do
use ExUnit.Case, async: true
alias AprsmeWeb.PacketsLive.Index
describe "extract_coordinate/2" do
test "extracts lat from direct :lat key" do
packet = %{lat: 35.123456, lon: -75.654321}
assert Index.extract_coordinate(packet, :lat) == 35.123456
end
test "extracts lon from direct :lon key" do
packet = %{lat: 35.0, lon: -75.654321}
assert Index.extract_coordinate(packet, :lon) == -75.654321
end
test "extracts lat from Packet struct with location" do
packet = %Aprsme.Packet{location: %Geo.Point{coordinates: {-75.0, 35.0}, srid: 4326}}
assert Index.extract_coordinate(packet, :lat) == 35.0
end
test "extracts lon from Packet struct with location" do
packet = %Aprsme.Packet{location: %Geo.Point{coordinates: {-75.0, 35.0}, srid: 4326}}
assert Index.extract_coordinate(packet, :lon) == -75.0
end
test "extracts lat from data_extended latitude" do
packet = %{data_extended: %{latitude: 35.5, longitude: -75.5}}
assert Index.extract_coordinate(packet, :lat) == 35.5
end
test "extracts lon from data_extended longitude" do
packet = %{data_extended: %{latitude: 35.5, longitude: -75.5}}
assert Index.extract_coordinate(packet, :lon) == -75.5
end
test "returns nil for missing data" do
assert Index.extract_coordinate(%{}, :lat) == nil
assert Index.extract_coordinate(%{}, :lon) == nil
end
test "prefers direct keys over data_extended" do
packet = %{lat: 10.0, lon: 20.0, data_extended: %{latitude: 30.0, longitude: 40.0}}
assert Index.extract_coordinate(packet, :lat) == 10.0
assert Index.extract_coordinate(packet, :lon) == 20.0
end
end
describe "format_coordinate/1" do
test "formats float to 6 decimal places" do
assert Index.format_coordinate(35.123456789) == "35.123457"
end
test "formats integer" do
assert Index.format_coordinate(35) == "35"
end
test "truncates long binary coordinate" do
assert Index.format_coordinate("35.12345678901") == "35.123456"
end
test "passes through short binary" do
assert Index.format_coordinate("35.12") == "35.12"
end
test "returns empty string for nil" do
assert Index.format_coordinate(nil) == ""
end
end
end