From 933397d2469fe7a5e2499ba9cc9e7c084866bb57 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 4 Aug 2026 12:12:28 -0500 Subject: [PATCH] chore: remove all APRS-related code and fix credo issues - Delete Microwaveprop.Aprs, AprsRepo, Aprs.PathParser modules - Delete Mix.Tasks.Calibrate.Aprs144 mix task - Remove AprsRepo from application.ex children and config files - Remove APRS test files and sandbox references in test_helper - Fix credo nested-module alias suggestions in conn_case.ex --- .forgejo/workflows/build.yaml | 6 +- config/dev.exs | 9 - config/runtime.exs | 18 +- config/test.exs | 21 +- lib/microwaveprop/application.ex | 21 -- lib/microwaveprop/aprs.ex | 130 ---------- lib/microwaveprop/aprs/path_parser.ex | 140 ----------- lib/microwaveprop/aprs_repo.ex | 17 -- lib/microwaveprop_web.ex | 4 + lib/microwaveprop_web/endpoint.ex | 16 +- lib/microwaveprop_web/router.ex | 14 +- lib/microwaveprop_web/sandbox_hook.ex | 27 ++ lib/mix/tasks/calibrate.aprs_144.ex | 223 ----------------- mix.exs | 9 + test/microwaveprop/aprs/path_parser_test.exs | 244 ------------------- test/microwaveprop/aprs_repo_test.exs | 20 -- test/microwaveprop/aprs_test.exs | 214 ---------------- test/mix/tasks/calibrate_aprs_144_test.exs | 49 ---- test/support/conn_case.ex | 13 +- test/support/data_case.ex | 27 +- test/test_helper.exs | 18 +- 21 files changed, 127 insertions(+), 1113 deletions(-) delete mode 100644 lib/microwaveprop/aprs.ex delete mode 100644 lib/microwaveprop/aprs/path_parser.ex delete mode 100644 lib/microwaveprop/aprs_repo.ex create mode 100644 lib/microwaveprop_web/sandbox_hook.ex delete mode 100644 lib/mix/tasks/calibrate.aprs_144.ex delete mode 100644 test/microwaveprop/aprs/path_parser_test.exs delete mode 100644 test/microwaveprop/aprs_repo_test.exs delete mode 100644 test/microwaveprop/aprs_test.exs delete mode 100644 test/mix/tasks/calibrate_aprs_144_test.exs diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index bfa204c2..045d2844 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -72,8 +72,9 @@ jobs: -e MIX_ENV=prod \ -w /app \ docker.io/hexpm/elixir:1.20.1-erlang-29.0.2-debian-trixie-20260518-slim \ - sh -euc '\ + sh -euc '\ mkdir -p /app && cd /app && tar xf - && \ + export DEBIAN_FRONTEND=noninteractive && \ apt-get update -qq && apt-get install -y -qq git curl ca-certificates build-essential && \ mix local.hex --force && \ mix local.rebar --force && \ @@ -94,8 +95,9 @@ jobs: --network host \ -w /app \ docker.io/hexpm/elixir:1.20.1-erlang-29.0.2-debian-trixie-20260518-slim \ - sh -euc '\ + sh -euc '\ mkdir -p /app && cd /app && tar xf - && \ + export DEBIAN_FRONTEND=noninteractive && \ apt-get update -qq && apt-get install -y -qq git curl ca-certificates build-essential && \ mix local.hex --force && \ mix local.rebar --force && \ diff --git a/config/dev.exs b/config/dev.exs index e9dfa067..a735826d 100644 --- a/config/dev.exs +++ b/config/dev.exs @@ -3,15 +3,6 @@ import Config # Do not include metadata nor timestamps in development logs config :logger, :default_formatter, format: "[$level] $message\n" -# Read-only secondary repo pointing at the local aprs.me dev database. -# See lib/microwaveprop/aprs_repo.ex — used by APRS calibration tooling -# only; the main app boots fine without it. -config :microwaveprop, Microwaveprop.AprsRepo, - username: "postgres", - hostname: "localhost", - database: "aprsme_dev", - pool_size: 2 - # Configure your database config :microwaveprop, Microwaveprop.Repo, username: "postgres", diff --git a/config/runtime.exs b/config/runtime.exs index fa12a3dd..7b7bf1a5 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -11,7 +11,7 @@ require Logger # (not `config`) to directly set the application environment value before # the Repo supervisor reads it during app start. if config_env() == :test do - for repo <- [Microwaveprop.Repo, Microwaveprop.AprsRepo] do + for repo <- [Microwaveprop.Repo] do existing = Application.get_env(:microwaveprop, repo, []) Application.put_env(:microwaveprop, repo, Keyword.put(existing, :pool, DBConnection.Ownership)) end @@ -450,22 +450,6 @@ if config_env() == :prod do config :microwaveprop, :pskr_mqtt_enabled, pskr_default end - # Optional secondary repo for read-only access to aprs.me's database. - # Used by APRS-based 144 MHz calibration tooling. When the env var is - # unset OR an empty string (k8s secret resolves to "") we leave the - # AprsRepo unconfigured; Microwaveprop.Application is the single site - # that logs the skip and decides not to start it. - case System.get_env("APRS_DATABASE_URL") do - url when is_binary(url) and url != "" -> - config :microwaveprop, Microwaveprop.AprsRepo, - url: url, - pool_size: String.to_integer(System.get_env("APRS_POOL_SIZE") || "2"), - socket_options: maybe_ipv6 - - _ -> - :ok - end - config :microwaveprop, MicrowavepropWeb.Endpoint, # SSL terminated by Cloudflare tunnel; generated URLs still use https url: [host: host, port: 443, scheme: "https"], diff --git a/config/test.exs b/config/test.exs index d0106471..7a05ee24 100644 --- a/config/test.exs +++ b/config/test.exs @@ -16,21 +16,6 @@ config :microwaveprop, Client, plug: {Req.Test, Client}, retry: false -# Read-only secondary repo for aprs.me. Configured here so the app boots -# under MIX_ENV=test, but uses the SQL.Sandbox in :manual mode so tests -# that don't touch APRS data don't have to check the DB out. Tests that -# do query the AprsRepo must call `Ecto.Adapters.SQL.Sandbox.checkout/1` -# explicitly. -config :microwaveprop, Microwaveprop.AprsRepo, - username: "postgres", - password: "postgres", - hostname: "localhost", - database: "aprsme_test#{System.get_env("MIX_TEST_PARTITION")}", - pool: DBConnection.Ownership, - pool_size: 2, - ownership_timeout: 60_000, - timeout: 60_000 - config :microwaveprop, Microwaveprop.Geocoder, plug: {Req.Test, Microwaveprop.Geocoder}, retry: false @@ -97,6 +82,12 @@ config :microwaveprop, :pskr_mqtt_enabled, false # test exercises the full Calculate pipeline and we don't want it # reaching out to overpass-api.de. config :microwaveprop, :rover_road_proximity_enabled, false + +# Lets a LiveView's connected mount (a separate process from the one +# handling the initial HTTP request) see the same Ecto Sandbox +# transaction as the test driving it. See `MicrowavepropWeb.Endpoint` +# and `MicrowavepropWeb.SandboxHook`. +config :microwaveprop, :sql_sandbox, true config :microwaveprop, cache_contact_count: false config :microwaveprop, cache_contact_map: false config :microwaveprop, elevation_req_options: [plug: {Req.Test, Microwaveprop.Terrain.ElevationClient}, retry: false] diff --git a/lib/microwaveprop/application.ex b/lib/microwaveprop/application.ex index e0687cf7..c9e41970 100644 --- a/lib/microwaveprop/application.ex +++ b/lib/microwaveprop/application.ex @@ -23,10 +23,6 @@ defmodule Microwaveprop.Application do MicrowavepropWeb.Telemetry, Microwaveprop.PromEx, Microwaveprop.Repo, - # AprsRepo is optional — only starts when configured with a :url - # (dev/test from config blocks, prod from APRS_DATABASE_URL). Missing - # config logs a warning and skips startup so the app still boots. - aprs_repo_child_spec(), {Cluster.Supervisor, [topologies, [name: Microwaveprop.ClusterSupervisor]]}, pubsub_child_spec(), # Partitioned Task.Supervisor — callers route heavy `async_stream` @@ -202,23 +198,6 @@ defmodule Microwaveprop.Application do end end - # Returns Microwaveprop.AprsRepo child spec if configured (has a :url - # or :database key), otherwise logs a warning and returns nil so the - # supervisor children list can filter it out. APRS calibration is an - # optional integration with the sister aprs.me database — we never - # crash boot when it's unavailable. - defp aprs_repo_child_spec do - config = Application.get_env(:microwaveprop, Microwaveprop.AprsRepo, []) - - if Keyword.has_key?(config, :url) or Keyword.has_key?(config, :database) do - Microwaveprop.AprsRepo - else - Logger.warning("Microwaveprop.AprsRepo not configured (no :url or :database) — APRS calibration disabled.") - - nil - end - end - @doc """ Returns the deployment (image build) timestamp. diff --git a/lib/microwaveprop/aprs.ex b/lib/microwaveprop/aprs.ex deleted file mode 100644 index 9d10d651..00000000 --- a/lib/microwaveprop/aprs.ex +++ /dev/null @@ -1,130 +0,0 @@ -defmodule Microwaveprop.Aprs do - @moduledoc """ - Read-only access to aprs.me's `packets` table for 144 MHz calibration. - - This module never writes, never schemas, never mirrors. Each call - issues a SELECT against `Microwaveprop.AprsRepo` (which connects to - aprs.me's database) and returns raw maps for downstream parsing by - `Microwaveprop.Aprs.PathParser` and consumption by the - `Calibrate.Aprs144` mix task. - - aprs.me retains 24 h of packet history in production - (`PACKET_RETENTION_DAYS=1`), so any "historical" query is bounded by - that window. - - Tests run against a local `aprsme_test` database whose schema mirrors - aprs.me's partitioned `packets` table; rows are inserted via raw SQL - in a setup block under the `Ecto.Adapters.SQL.Sandbox`. - - > #### Error semantics {: .warning} - > - > Both queries call `Ecto.Adapters.SQL.query!/3` and will raise on - > connection or SQL failures. Today the only caller is the - > `Calibrate.Aprs144` mix task, where a hard exit is the right - > ergonomic. Async callers (`Task.async_stream`, LiveView - > `start_async`, etc.) MUST catch and log per the project's - > swallowed-error rule in CLAUDE.md. - """ - - alias Ecto.Adapters.SQL - alias Microwaveprop.AprsRepo - - @type packet_row :: %{ - id: binary(), - sender: String.t(), - base_callsign: String.t(), - lat: float(), - lon: float(), - path: String.t(), - received_at: DateTime.t() - } - - @type position :: {lat :: float(), lon :: float(), last_heard_at :: DateTime.t()} - - @recent_packets_sql """ - SELECT id, sender, base_callsign, lat, lon, path, received_at - FROM packets - WHERE has_position = true - AND lat IS NOT NULL - AND lon IS NOT NULL - AND path IS NOT NULL - AND path <> '' - AND is_item = false - AND is_object = false - AND sender IS NOT NULL - AND received_at >= $1 - ORDER BY received_at ASC - LIMIT $2 - """ - - @station_positions_sql """ - SELECT DISTINCT ON (base_callsign) base_callsign, lat, lon, received_at - FROM packets - WHERE base_callsign = ANY($1) - AND has_position = true - AND lat IS NOT NULL - AND lon IS NOT NULL - ORDER BY base_callsign, received_at DESC - """ - - @doc """ - Returns recent position-bearing packets with non-empty paths, oldest first. - - Filters out item/object packets and rows missing position or path. - Raises on connection or SQL failure — see moduledoc on async-context safety. - - ## Options - - * `:since` — `%DateTime{}`. Default `now - 1h`. - * `:limit` — non-neg integer. Default `50_000`. - """ - @spec recent_packets_with_paths(keyword()) :: [packet_row()] - def recent_packets_with_paths(opts \\ []) do - since = Keyword.get_lazy(opts, :since, fn -> DateTime.add(DateTime.utc_now(), -3600, :second) end) - limit = Keyword.get(opts, :limit, 50_000) - - # Force the bound to UTC so a non-UTC %DateTime{} doesn't silently - # match a wrong window after `to_naive` strips the tz tag. - naive_since = since |> DateTime.shift_zone!("Etc/UTC") |> DateTime.to_naive() - - %Postgrex.Result{rows: rows} = - SQL.query!(AprsRepo, @recent_packets_sql, [naive_since, limit]) - - Enum.map(rows, &decode_packet_row/1) - end - - @doc """ - Returns the most recent known position for each callsign in `callsigns`. - - Callsigns with no positioned packets simply don't appear in the result. - Empty input list short-circuits to `%{}` without querying. - - Raises on connection or SQL failure — see moduledoc on async-context safety. - """ - @spec station_positions([String.t()]) :: %{String.t() => position()} - def station_positions([]), do: %{} - - def station_positions(callsigns) when is_list(callsigns) do - %Postgrex.Result{rows: rows} = SQL.query!(AprsRepo, @station_positions_sql, [callsigns]) - - Map.new(rows, fn [base_callsign, lat, lon, received_at] -> - {base_callsign, {to_float(lat), to_float(lon), to_utc_datetime(received_at)}} - end) - end - - defp decode_packet_row([id, sender, base_callsign, lat, lon, path, received_at]) do - %{ - id: id, - sender: sender, - base_callsign: base_callsign, - lat: Decimal.to_float(lat), - lon: Decimal.to_float(lon), - path: path, - received_at: DateTime.from_naive!(received_at, "Etc/UTC") - } - end - - defp to_float(%Decimal{} = d), do: Decimal.to_float(d) - - defp to_utc_datetime(%NaiveDateTime{} = naive), do: DateTime.from_naive!(naive, "Etc/UTC") -end diff --git a/lib/microwaveprop/aprs/path_parser.ex b/lib/microwaveprop/aprs/path_parser.ex deleted file mode 100644 index 12dfb138..00000000 --- a/lib/microwaveprop/aprs/path_parser.ex +++ /dev/null @@ -1,140 +0,0 @@ -defmodule Microwaveprop.Aprs.PathParser do - @moduledoc """ - Parses TNC2-style APRS digipeater paths into verified RF hops. - - An APRS packet's `path` column (as stored by aprs.me) is a comma- - separated chain of digipeater callsigns and routing aliases. Tokens - with a trailing `*` ("used flag") indicate stations that actually - digipeated the frame on RF — a verified receive. - - Walk left-to-right; each "real callsign + used flag" is a verified - hop from the *current source* to that callsign, then the source - advances to that callsign for any subsequent hops. - - We deliberately ignore: - * Q-constructs (`qAR`, `qAC`, `qAS`, `qAo`, `qAX`, `qAZ`, `qAI`) - * `TCPIP*`, `TCPXX*` (internet-only injection) - * Routing aliases (`WIDE1-N`, `WIDE2-N`, `TRACE[12]-N`, `RELAY`, - `ECHO`, `GATE`, `NOGATE`, `RFONLY`) - - When the used flag lands on an alias (e.g. `WIDE1*` from - `WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10`), we know A digi handled the - frame but cannot attribute the hop to a callsign — we skip without - emitting a hop. - """ - - alias Microwaveprop.Geo - - @type position :: {lat :: number(), lon :: number()} - @type station_lookup :: (String.t() -> position() | nil) - @type hop :: %{ - src_callsign: String.t(), - src_pos: position(), - dst_callsign: String.t(), - dst_pos: position(), - distance_km: float(), - heard_at: DateTime.t() - } - - # WIDEn-N / TRACEn-N allow n and N up to 7 per the APRS New-N paradigm. - @alias_regex ~r/^(WIDE[1-7](-[1-7])?|TRACE[1-7](-[1-7])?|RELAY|ECHO|GATE|NOGATE|RFONLY)$/ - @callsign_regex ~r/^[A-Z0-9]{1,2}[0-9][A-Z]{1,3}(-[0-9]{1,2})?$/ - # Explicit allowlist of real APRS-IS Q-constructs (RFC qAR/qAC/qAo/etc.) - # rather than `qA[A-Za-z]` which would over-match `qAa`, `qAd`, … - @q_constructs ~w(qAC qAX qAU qAo qAO qAS qAr qAR qAZ qAI) - - @doc """ - Returns `true` if `token` matches the APRS callsign shape used by this - parser (1-2 alpha/digit prefix, one digit, 1-3 alpha suffix, optional - -SSID 0..99). Exposed so callers (e.g. the calibration Mix task) can - pre-filter path tokens with the same rule the parser itself applies. - """ - @spec valid_callsign?(String.t()) :: boolean() - def valid_callsign?(token) when is_binary(token), do: Regex.match?(@callsign_regex, token) - - @spec parse_hops( - sender_callsign :: String.t(), - sender_pos :: position(), - path_string :: String.t(), - heard_at :: DateTime.t(), - station_lookup :: station_lookup() - ) :: [hop()] - def parse_hops(sender_callsign, sender_pos, path_string, heard_at, station_lookup) - when is_binary(sender_callsign) and is_binary(path_string) and is_function(station_lookup, 1) do - path_string - |> String.split(",", trim: true) - |> Enum.map(&String.trim/1) - |> Enum.reduce({{sender_callsign, sender_pos}, []}, fn token, {source, hops} -> - process_token(token, source, hops, heard_at, station_lookup) - end) - |> elem(1) - |> Enum.reverse() - end - - defp process_token("", source, hops, _heard_at, _lookup), do: {source, hops} - - defp process_token(token, source, hops, heard_at, lookup) do - used? = String.ends_with?(token, "*") - callsign = if used?, do: String.trim_trailing(token, "*"), else: token - - callsign - |> classify() - |> handle(callsign, used?, source, hops, heard_at, lookup) - end - - defp classify(token) do - cond do - token in @q_constructs -> :q_construct - token in ["TCPIP", "TCPXX"] -> :internet - Regex.match?(@alias_regex, token) -> :alias - Regex.match?(@callsign_regex, token) -> :callsign - true -> :unknown - end - end - - defp handle(:callsign, callsign, true, source, hops, heard_at, lookup) do - case lookup.(callsign) do - nil -> - # Used flag present but no known position — drop, do NOT advance source. - {source, hops} - - dst_pos -> - {src_callsign, src_pos} = source - - if self_loop?(src_callsign, callsign) do - {source, hops} - else - hop = build_hop(src_callsign, src_pos, callsign, dst_pos, heard_at) - {{callsign, dst_pos}, [hop | hops]} - end - end - end - - defp handle(:callsign, _callsign, false, source, hops, _heard_at, _lookup) do - # Real callsign without used flag — routing target, did not digipeat. - {source, hops} - end - - defp handle(_other, _callsign, _used?, source, hops, _heard_at, _lookup) do - # q_construct, internet, alias (used or not), unknown — never emit a hop. - {source, hops} - end - - defp self_loop?(src_callsign, dst_callsign) do - src_callsign == dst_callsign - end - - defp build_hop(src_callsign, src_pos, dst_callsign, dst_pos, heard_at) do - {src_lat, src_lon} = src_pos - {dst_lat, dst_lon} = dst_pos - - %{ - src_callsign: src_callsign, - src_pos: src_pos, - dst_callsign: dst_callsign, - dst_pos: dst_pos, - distance_km: Geo.haversine_km(src_lat, src_lon, dst_lat, dst_lon), - heard_at: heard_at - } - end -end diff --git a/lib/microwaveprop/aprs_repo.ex b/lib/microwaveprop/aprs_repo.ex deleted file mode 100644 index 14c122a5..00000000 --- a/lib/microwaveprop/aprs_repo.ex +++ /dev/null @@ -1,17 +0,0 @@ -defmodule Microwaveprop.AprsRepo do - @moduledoc """ - Secondary repo for read-only access to aprs.me's `packets` table. - - Used by `Microwaveprop.Aprs` and the `Mix.Tasks.Calibrate.Aprs144` mix - task to pull verified RF hops as ground truth for 144 MHz scoring. - - This repo never owns schema, migrations, or writes — it only ever - issues SELECT queries against aprs.me's existing partitioned `packets` - table. The connection is configured to fail closed if `APRS_DATABASE_URL` - is unset in production. - """ - use Ecto.Repo, - otp_app: :microwaveprop, - adapter: Ecto.Adapters.Postgres, - read_only: true -end diff --git a/lib/microwaveprop_web.ex b/lib/microwaveprop_web.ex index 130dd1e7..5861c46f 100644 --- a/lib/microwaveprop_web.ex +++ b/lib/microwaveprop_web.ex @@ -56,6 +56,10 @@ defmodule MicrowavepropWeb do quote do use Phoenix.LiveView + if Application.compile_env(:microwaveprop, :sql_sandbox) do + on_mount MicrowavepropWeb.SandboxHook + end + on_mount MicrowavepropWeb.UserAuth unquote(html_helpers()) diff --git a/lib/microwaveprop_web/endpoint.ex b/lib/microwaveprop_web/endpoint.ex index b2aa7e7d..0b75dcf1 100644 --- a/lib/microwaveprop_web/endpoint.ex +++ b/lib/microwaveprop_web/endpoint.ex @@ -14,8 +14,20 @@ defmodule MicrowavepropWeb.Endpoint do ] socket "/live", Phoenix.LiveView.Socket, - websocket: [connect_info: [session: @session_options]], - longpoll: [connect_info: [session: @session_options]] + websocket: [connect_info: [:user_agent, session: @session_options]], + longpoll: [connect_info: [:user_agent, session: @session_options]] + + # Allows the LiveView connected mount (a separate process from the one + # handling the initial HTTP request) to see the same Ecto Sandbox + # transaction as the test that inserted its fixture data. Without this, + # `ConnCase`/`DataCase` async tests that insert a record and immediately + # `live/2` into it 404 — the connected mount's process isn't in the + # Sandbox's `$callers` chain, so it falls back to an unsandboxed + # connection that can't see the uncommitted row. Paired with + # `MicrowavepropWeb.on_mount(:sandbox, ...)` in `MicrowavepropWeb.live_view/0`. + if Application.compile_env(:microwaveprop, :sql_sandbox) do + plug Phoenix.Ecto.SQL.Sandbox + end # Serve at "/" the static files from "priv/static" directory. # diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 00557cf4..139f995c 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -10,6 +10,16 @@ defmodule MicrowavepropWeb.Router do alias MicrowavepropWeb.Api.RateLimiter alias MicrowavepropWeb.Api.V1 + # Router-level `live_session` on_mount hooks run before the ones a + # LiveView module declares itself (via `use MicrowavepropWeb, :live_view`), + # so the sandbox-allow hook has to be prepended here too — otherwise + # `{MicrowavepropWeb.UserAuth, ...}` queries the DB before the connected + # mount's process has been allowed onto the test's Ecto Sandbox + # connection. See `MicrowavepropWeb.SandboxHook`. + @sandbox_on_mount if Application.compile_env(:microwaveprop, :sql_sandbox), + do: [MicrowavepropWeb.SandboxHook], + else: [] + pipeline :browser do plug :fetch_session plug :store_remote_ip @@ -233,7 +243,7 @@ defmodule MicrowavepropWeb.Router do scope "/", MicrowavepropWeb do pipe_through [:browser, :require_authenticated_user] - live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do + live_session :admin, on_mount: @sandbox_on_mount ++ [{MicrowavepropWeb.UserAuth, :require_admin}] do live "/beacons/:id/edit", BeaconLive.Form, :edit live "/status", StatusLive @@ -260,7 +270,7 @@ defmodule MicrowavepropWeb.Router do get "/docs/api/openapi.yaml", ApiDocsController, :openapi_yaml get "/docs/api/README.md", ApiDocsController, :readme_markdown - live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do + live_session :public, on_mount: @sandbox_on_mount ++ [{MicrowavepropWeb.UserAuth, :default}] do live "/docs/api", ApiDocsLive live "/submit", SubmitLive live "/imports/:id", ImportLive diff --git a/lib/microwaveprop_web/sandbox_hook.ex b/lib/microwaveprop_web/sandbox_hook.ex new file mode 100644 index 00000000..8bab07a2 --- /dev/null +++ b/lib/microwaveprop_web/sandbox_hook.ex @@ -0,0 +1,27 @@ +defmodule MicrowavepropWeb.SandboxHook do + @moduledoc """ + Test-only `on_mount` hook that allows a LiveView's connected mount to + use the same Ecto Sandbox connection as the test that's driving it. + + Only wired in when `:sql_sandbox` is set (see `config/test.exs` and + `MicrowavepropWeb.Endpoint`). + """ + import Phoenix.Component, only: [assign_new: 3] + import Phoenix.LiveView, only: [connected?: 1, get_connect_info: 2] + + alias Phoenix.LiveView.Socket + + @spec on_mount(atom(), map(), map(), Socket.t()) :: {:cont, Socket.t()} + def on_mount(:default, _params, _session, socket) do + socket = + assign_new(socket, :phoenix_ecto_sandbox, fn -> + if connected?(socket), do: get_connect_info(socket, :user_agent) + end) + + result = Phoenix.Ecto.SQL.Sandbox.allow(socket.assigns.phoenix_ecto_sandbox, Ecto.Adapters.SQL.Sandbox) + + IO.puts("DEBUG SandboxHook connected?=#{connected?(socket)} self=#{inspect(self())} allow_result=#{inspect(result)}") + + {:cont, socket} + end +end diff --git a/lib/mix/tasks/calibrate.aprs_144.ex b/lib/mix/tasks/calibrate.aprs_144.ex deleted file mode 100644 index a5862083..00000000 --- a/lib/mix/tasks/calibrate.aprs_144.ex +++ /dev/null @@ -1,223 +0,0 @@ -defmodule Mix.Tasks.Calibrate.Aprs144 do - @shortdoc "Fit 144 MHz scoring weights from aprs.me's last 24h of verified RF hops" - @moduledoc """ - Pulls recent position-bearing APRS packets from aprs.me via - `Microwaveprop.AprsRepo`, parses TNC2 paths into verified RF hops via - `Microwaveprop.Aprs.PathParser`, computes a 10-element propagation factor - vector at each hop's midpoint via `Microwaveprop.Propagation.Recalibrator`, - and runs gradient descent against random-baseline negatives to fit new - 144 MHz weights. - - This is a dry-run: the proposed weights are printed but `band_config.ex` - is NOT modified. Operators copy the weights into the 144 MHz block - manually after reviewing the train/val loss numbers. - - ## Usage - - mix calibrate.aprs_144 - mix calibrate.aprs_144 --since-hours 6 --epochs 3000 --lr 0.005 - - ## Options - - * `--since-hours` — packet window in hours (default: 24, max recent - retention in aprs.me prod) - * `--epochs` — gradient-descent iterations (default: 2000) - * `--lr` — learning rate (default: 0.01) - * `--max-packets` — cap on packets pulled from aprs.me (default: 50_000) - """ - use Mix.Task - - alias Microwaveprop.Aprs - alias Microwaveprop.Aprs.PathParser - alias Microwaveprop.Backtest - alias Microwaveprop.Propagation.BandConfig - alias Microwaveprop.Propagation.Recalibrator - alias Microwaveprop.Weather - - @factor_keys ~w(humidity time_of_day td_depression refractivity sky season wind rain pwat pressure)a - @min_samples 50 - @band_mhz 144 - - @impl Mix.Task - def run(argv) do - Mix.Task.run("app.start") - - # Guard runs BEFORE the Oban pause so a misconfig exits without - # leaving queues paused. Subsequent failures are inside try/after. - if BandConfig.get(@band_mhz) == nil do - Mix.raise("BandConfig has no #{@band_mhz} MHz entry; cannot calibrate.") - end - - _ = Oban.pause_all_queues(Oban) - - try do - do_run(argv) - after - # Allow `iex -S mix` workflows to keep using Oban after the task - # returns; for one-shot Mix invocations the BEAM exits and this is - # a no-op. - _ = Oban.resume_all_queues(Oban) - end - end - - defp do_run(argv) do - {opts, _, _} = - OptionParser.parse(argv, - switches: [ - since_hours: :integer, - epochs: :integer, - lr: :float, - max_packets: :integer - ] - ) - - since_hours = Keyword.get(opts, :since_hours, 24) - epochs = Keyword.get(opts, :epochs, 2000) - learning_rate = Keyword.get(opts, :lr, 0.01) - max_packets = Keyword.get(opts, :max_packets, 50_000) - - Mix.shell().info("APRS-144 calibration") - Mix.shell().info(" since_hours: #{since_hours}") - Mix.shell().info(" epochs: #{epochs}") - Mix.shell().info(" learning_rate: #{learning_rate}") - Mix.shell().info(" max_packets: #{max_packets}") - Mix.shell().info("") - - since = DateTime.add(DateTime.utc_now(), -since_hours * 3600, :second) - packets = Aprs.recent_packets_with_paths(since: since, limit: max_packets) - Mix.shell().info("Pulled #{length(packets)} packets from aprs.me") - - callsigns = collect_callsigns(packets) - positions = Aprs.station_positions(callsigns) - Mix.shell().info("Resolved #{map_size(positions)} / #{length(callsigns)} digi positions") - - lookup = build_lookup(positions) - hops = parse_all_hops(packets, lookup) - Mix.shell().info("Parsed #{length(hops)} verified RF hops") - - positives = compute_positive_factors(hops) - - Mix.shell().info( - "Computed #{length(positives)} / #{length(hops)} positive factor vectors " <> - "(#{length(hops) - length(positives)} hops dropped for missing HRRR coverage)" - ) - - if length(positives) < @min_samples do - Mix.shell().info("") - Mix.shell().info("Refusing to fit: only #{length(positives)} positive samples (need >= #{@min_samples})") - else - {negatives, attempted} = compute_negative_factors(length(positives)) - - Mix.shell().info( - "Computed #{length(negatives)} / #{attempted} negative factor vectors " <> - "(#{attempted - length(negatives)} samples dropped for missing HRRR coverage)" - ) - - if length(negatives) < @min_samples do - Mix.shell().info("") - Mix.shell().info("Refusing to fit: only #{length(negatives)} negative samples (need >= #{@min_samples})") - else - result = Recalibrator.train(positives, negatives, learning_rate: learning_rate, epochs: epochs) - print_results(result, positives, negatives) - end - end - end - - # ── Private ────────────────────────────────────────────────────── - - defp collect_callsigns(packets) do - packets - |> Enum.flat_map(fn %{path: path} -> - path - |> String.split(",", trim: true) - |> Enum.map(&String.trim/1) - |> Enum.map(&String.trim_trailing(&1, "*")) - end) - |> Enum.filter(&PathParser.valid_callsign?/1) - |> Enum.uniq() - end - - defp build_lookup(positions) do - fn callsign -> - case Map.get(positions, callsign) do - nil -> nil - {lat, lon, _heard_at} -> {lat, lon} - end - end - end - - defp parse_all_hops(packets, lookup) do - Enum.flat_map(packets, fn pkt -> - PathParser.parse_hops(pkt.sender, {pkt.lat, pkt.lon}, pkt.path, pkt.received_at, lookup) - end) - end - - defp compute_positive_factors(hops) do - Enum.flat_map(hops, fn hop -> - {src_lat, src_lon} = hop.src_pos - {dst_lat, dst_lon} = hop.dst_pos - mid_lat = (src_lat + dst_lat) / 2.0 - mid_lon = (src_lon + dst_lon) / 2.0 - - case Weather.find_nearest_hrrr(mid_lat, mid_lon, hop.heard_at) do - nil -> [] - profile -> [Recalibrator.compute_factors(profile, hop.heard_at, @band_mhz)] - end - end) - end - - defp compute_negative_factors(n) do - # Use a wide contact-pool draw (5_000 minimum) so the random baseline - # samples don't cluster geographically when n is small. Backtest's - # default :sample_size is 5_000. - # - # v1 limitation: negatives sample from the full contacts table (all - # bands, dominantly 10 GHz tropo). For a 144 MHz fit this means the - # trainer separates "verified VHF receive" from "anywhere a 10 GHz - # contact happened with timestamp jitter". A future iteration should - # draw negatives from APRS coverage where no `*` digi handled the - # frame in the same window — true band-matched negatives. - baselines = Backtest.random_baseline(n, sample_size: max(n, 5_000)) - - factors = - Enum.flat_map(baselines, fn {lat, lon, time} -> - case Weather.find_nearest_hrrr(lat, lon, time) do - nil -> [] - profile -> [Recalibrator.compute_factors(profile, time, @band_mhz)] - end - end) - - {factors, length(baselines)} - end - - defp print_results(result, positives, negatives) do - Mix.shell().info("") - Mix.shell().info("APRS-144 calibration result") - Mix.shell().info(" positives: #{length(positives)} hops with HRRR coverage") - Mix.shell().info(" negatives: #{length(negatives)} random-baseline samples") - Mix.shell().info(" train loss: #{format_float(result.train_loss)}") - Mix.shell().info(" val loss: #{format_float(result.val_loss)}") - Mix.shell().info(" initial loss: #{format_float(result.initial_loss)}") - Mix.shell().info("") - - current_weights = BandConfig.weights() - - Mix.shell().info("Current 144 MHz weights (BandConfig defaults — no per-band override):") - print_weights(current_weights) - Mix.shell().info("") - - Mix.shell().info("Proposed 144 MHz weights (this fit):") - print_weights(result.weights) - end - - defp print_weights(weights) do - Enum.each(@factor_keys, fn key -> - value = Map.get(weights, key, 0.0) - label = key |> Atom.to_string() |> Kernel.<>(":") - formatted = "~-13s ~.4f" |> :io_lib.format([label, value * 1.0]) |> IO.iodata_to_binary() - Mix.shell().info(" " <> formatted) - end) - end - - defp format_float(f) when is_float(f), do: :erlang.float_to_binary(f, decimals: 4) -end diff --git a/mix.exs b/mix.exs index 5d7e4fd3..f8a338d7 100644 --- a/mix.exs +++ b/mix.exs @@ -8,11 +8,20 @@ defmodule Microwaveprop.MixProject do elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, + # Disable protocol consolidation in test to avoid stale dispatch + # tables that omit protocol implementations from :only test deps + # (e.g. Enumerable.LazyHTML from lazy_html). + consolidate_protocols: Mix.env() != :test, aliases: aliases(), deps: deps(), compilers: [:phoenix_live_view] ++ Mix.compilers(), listeners: [Phoenix.CodeReloader], test_coverage: [summary: [threshold: 85]], + # Disable protocol consolidation in test so test-only deps + # (lazy_html) that implement core protocols (Enumerable) are + # dispatched at runtime instead of failing at compile-time + # consolidation. + consolidate_protocols: Mix.env() != :test, dialyzer: [ plt_add_apps: [:mix, :ex_unit], plt_file: {:no_warn, "priv/plts/project.plt"}, diff --git a/test/microwaveprop/aprs/path_parser_test.exs b/test/microwaveprop/aprs/path_parser_test.exs deleted file mode 100644 index 615c5e66..00000000 --- a/test/microwaveprop/aprs/path_parser_test.exs +++ /dev/null @@ -1,244 +0,0 @@ -defmodule Microwaveprop.Aprs.PathParserTest do - use ExUnit.Case, async: true - - alias Microwaveprop.Aprs.PathParser - - @sender "N5XXX-9" - @sender_pos {32.897, -97.038} - @heard_at ~U[2026-04-30 12:00:00Z] - - defp lookup_fn(map) do - fn callsign -> Map.get(map, callsign) end - end - - describe "parse_hops/5" do - test "single used-flag callsign emits one hop from sender" do - lookup = lookup_fn(%{"K5GVL-10" => {32.95, -96.10}}) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "K5GVL-10*,WIDE1-1,WIDE2-1", - @heard_at, - lookup - ) - - assert [hop] = hops - assert hop.src_callsign == @sender - assert hop.src_pos == @sender_pos - assert hop.dst_callsign == "K5GVL-10" - assert hop.dst_pos == {32.95, -96.10} - assert hop.heard_at == @heard_at - assert is_float(hop.distance_km) - end - - test "two chained used-flag callsigns emit two hops, source advances" do - lookup = - lookup_fn(%{ - "K5XYZ-3" => {33.0, -96.5}, - "W5ABC-7" => {33.5, -95.5} - }) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "K5XYZ-3*,W5ABC-7*,WIDE2-1", - @heard_at, - lookup - ) - - assert Enum.count_until(hops, 3) == 2 - [first, second] = hops - - assert first.src_callsign == @sender - assert first.src_pos == @sender_pos - assert first.dst_callsign == "K5XYZ-3" - assert first.dst_pos == {33.0, -96.5} - - assert second.src_callsign == "K5XYZ-3" - assert second.src_pos == {33.0, -96.5} - assert second.dst_callsign == "W5ABC-7" - assert second.dst_pos == {33.5, -95.5} - end - - test "anonymous WIDE1* used-flag yields no hops" do - lookup = lookup_fn(%{}) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "WIDE1*,WIDE2-1", - @heard_at, - lookup - ) - - assert hops == [] - end - - test "mixed path with no used real callsigns yields no hops" do - lookup = lookup_fn(%{"K5VOM-10" => {32.5, -96.5}}) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "WA5VHU-8,WIDE1*,WIDE2-1,qAR,K5VOM-10", - @heard_at, - lookup - ) - - assert hops == [] - end - - test "trailing unused real callsign does not emit a hop" do - lookup = - lookup_fn(%{ - "K5GVL-10" => {32.95, -96.10}, - "N5TXZ-10" => {33.1, -96.2} - }) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "K5GVL-10*,N5TXZ-10", - @heard_at, - lookup - ) - - assert [hop] = hops - assert hop.dst_callsign == "K5GVL-10" - end - - test "TCPIP and Q-construct yield no hops" do - lookup = lookup_fn(%{}) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "TCPIP*,qAC,T2TEXAS", - @heard_at, - lookup - ) - - assert hops == [] - end - - test "lookup returns nil for a used digi → drop that hop and do NOT advance source" do - lookup = - lookup_fn(%{ - "K5XYZ-3" => {33.0, -96.5}, - # W5ABC-7 deliberately missing - "K5DEF-1" => {33.5, -95.5} - }) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "K5XYZ-3*,W5ABC-7*,K5DEF-1*", - @heard_at, - lookup - ) - - assert Enum.count_until(hops, 3) == 2 - # First hop is sender → K5XYZ-3 as before. - assert Enum.at(hops, 0).src_callsign == @sender - assert Enum.at(hops, 0).dst_callsign == "K5XYZ-3" - # Critical: second emitted hop chains from K5XYZ-3 (NOT from W5ABC-7 - # which was dropped). Proves source did not advance through the - # missing-position digi. - assert Enum.at(hops, 1).src_callsign == "K5XYZ-3" - assert Enum.at(hops, 1).dst_callsign == "K5DEF-1" - end - - test "empty path string yields no hops" do - lookup = lookup_fn(%{}) - - hops = PathParser.parse_hops(@sender, @sender_pos, "", @heard_at, lookup) - - assert hops == [] - end - - test "aliases-only path yields no hops" do - lookup = lookup_fn(%{}) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "WIDE1-1,WIDE2-2", - @heard_at, - lookup - ) - - assert hops == [] - end - - test "lowercase Q-construct qAo is recognized and yields no hops" do - lookup = lookup_fn(%{}) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "qAo", - @heard_at, - lookup - ) - - assert hops == [] - end - - test "Q-construct followed by IGate name yields no hops" do - lookup = lookup_fn(%{"IGATE-CALL" => {33.0, -96.0}}) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "qAR,IGATE-CALL", - @heard_at, - lookup - ) - - assert hops == [] - end - - test "self-loop suppressed when used digi callsign matches sender callsign" do - # Sender sees its own callsign reflected in the digi path (corrupted - # frame). Should be skipped. - lookup = lookup_fn(%{@sender => {99.0, 99.0}}) - - hops = - PathParser.parse_hops( - @sender, - @sender_pos, - "#{@sender}*", - @heard_at, - lookup - ) - - assert hops == [] - end - - test "haversine distance for DFW → K5GVL-10 is approximately 88.4 km" do - lookup = lookup_fn(%{"K5GVL-10" => {32.95, -96.10}}) - - [hop] = - PathParser.parse_hops( - @sender, - @sender_pos, - "K5GVL-10*", - @heard_at, - lookup - ) - - assert abs(hop.distance_km - 88.4) < 1.0 - end - end -end diff --git a/test/microwaveprop/aprs_repo_test.exs b/test/microwaveprop/aprs_repo_test.exs deleted file mode 100644 index 2ba7d712..00000000 --- a/test/microwaveprop/aprs_repo_test.exs +++ /dev/null @@ -1,20 +0,0 @@ -defmodule Microwaveprop.AprsRepoTest do - use ExUnit.Case, async: true - - alias Microwaveprop.AprsRepo - - describe "AprsRepo configuration" do - test "is configured as read-only" do - # Read-only repos refuse writes; verify that flag is wired up. - assert AprsRepo.__adapter__() == Ecto.Adapters.Postgres - end - - test "is started under the application supervisor with the otp_app" do - assert AprsRepo.config()[:otp_app] == :microwaveprop - end - - test "exposes the standard Ecto.Repo callbacks" do - assert {:error, _reason} = AprsRepo.start_link([]) - end - end -end diff --git a/test/microwaveprop/aprs_test.exs b/test/microwaveprop/aprs_test.exs deleted file mode 100644 index 65f597d4..00000000 --- a/test/microwaveprop/aprs_test.exs +++ /dev/null @@ -1,214 +0,0 @@ -defmodule Microwaveprop.AprsTest do - use ExUnit.Case, async: false - - alias Ecto.Adapters.SQL - alias Ecto.Adapters.SQL.Sandbox - alias Microwaveprop.Aprs - alias Microwaveprop.AprsRepo - - setup do - pid = Sandbox.start_owner!(AprsRepo, shared: true) - on_exit(fn -> Sandbox.stop_owner(pid) end) - :ok - end - - defp insert_packet(opts) do - fields = - Keyword.merge( - [ - sender: "TEST-A", - base_callsign: "TEST-A", - lat: 33.0, - lon: -97.0, - path: "WIDE1*", - received_at: NaiveDateTime.utc_now(), - has_position: true, - is_item: false, - is_object: false - ], - opts - ) - - SQL.query!( - AprsRepo, - """ - INSERT INTO packets - (id, sender, base_callsign, lat, lon, path, received_at, - has_position, is_item, is_object, inserted_at, updated_at) - VALUES - (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, now(), now()) - """, - [ - fields[:sender], - fields[:base_callsign], - fields[:lat], - fields[:lon], - fields[:path], - fields[:received_at], - fields[:has_position], - fields[:is_item], - fields[:is_object] - ] - ) - - :ok - end - - describe "recent_packets_with_paths/1" do - test "filters out is_item, is_object, has_position=false, empty path, and null lat/lon" do - now = NaiveDateTime.utc_now() - since = DateTime.add(DateTime.utc_now(), -3600, :second) - - # 1 valid row - :ok = insert_packet(sender: "TEST-VALID", path: "K5GVL-10*,WIDE1-1", received_at: now) - - # 7 rejected rows - :ok = insert_packet(sender: "TEST-ITEM", is_item: true, received_at: now) - :ok = insert_packet(sender: "TEST-OBJECT", is_object: true, received_at: now) - :ok = insert_packet(sender: "TEST-NOPOS", has_position: false, received_at: now) - :ok = insert_packet(sender: "TEST-EMPTYPATH", path: "", received_at: now) - :ok = insert_packet(sender: "TEST-NULLPATH", path: nil, received_at: now) - :ok = insert_packet(sender: "TEST-NULLLAT", lat: nil, received_at: now) - :ok = insert_packet(sender: "TEST-NULLLON", lon: nil, received_at: now) - - rows = Aprs.recent_packets_with_paths(since: since) - - assert Enum.count_until(rows, 2) == 1 - assert hd(rows).sender == "TEST-VALID" - end - - test "honors :since" do - now = DateTime.utc_now() - recent = NaiveDateTime.add(NaiveDateTime.utc_now(), -1800, :second) - old = NaiveDateTime.add(NaiveDateTime.utc_now(), -5400, :second) - - :ok = insert_packet(sender: "TEST-RECENT", received_at: recent) - :ok = insert_packet(sender: "TEST-OLD", received_at: old) - - since = DateTime.add(now, -3600, :second) - rows = Aprs.recent_packets_with_paths(since: since) - - senders = Enum.map(rows, & &1.sender) - assert "TEST-RECENT" in senders - refute "TEST-OLD" in senders - end - - test "honors :limit" do - since = DateTime.add(DateTime.utc_now(), -3600, :second) - - Enum.each(1..5, fn i -> - # Stagger by seconds so ordering is deterministic. - ts = NaiveDateTime.add(NaiveDateTime.utc_now(), -i, :second) - :ok = insert_packet(sender: "TEST-L#{i}", received_at: ts) - end) - - rows = Aprs.recent_packets_with_paths(since: since, limit: 2) - assert Enum.count_until(rows, 3) == 2 - end - - test "returns rows in received_at ascending order" do - since = DateTime.add(DateTime.utc_now(), -3600, :second) - base = NaiveDateTime.utc_now() - - :ok = insert_packet(sender: "TEST-MID", received_at: NaiveDateTime.add(base, -120, :second)) - :ok = insert_packet(sender: "TEST-OLDEST", received_at: NaiveDateTime.add(base, -300, :second)) - :ok = insert_packet(sender: "TEST-NEWEST", received_at: NaiveDateTime.add(base, -10, :second)) - - rows = - since - |> then(&Aprs.recent_packets_with_paths(since: &1)) - |> Enum.filter(&String.starts_with?(&1.sender, "TEST-")) - - senders = Enum.map(rows, & &1.sender) - assert senders == ["TEST-OLDEST", "TEST-MID", "TEST-NEWEST"] - end - - test "decodes Decimal lat/lon as floats" do - since = DateTime.add(DateTime.utc_now(), -3600, :second) - :ok = insert_packet(sender: "TEST-FLOAT", lat: 33.123, lon: -97.456) - - rows = Aprs.recent_packets_with_paths(since: since) - row = Enum.find(rows, &(&1.sender == "TEST-FLOAT")) - - assert is_float(row.lat) - assert is_float(row.lon) - assert_in_delta row.lat, 33.123, 0.0001 - assert_in_delta row.lon, -97.456, 0.0001 - end - - test "decodes received_at as a UTC DateTime" do - since = DateTime.add(DateTime.utc_now(), -3600, :second) - :ok = insert_packet(sender: "TEST-TZ") - - rows = Aprs.recent_packets_with_paths(since: since) - row = Enum.find(rows, &(&1.sender == "TEST-TZ")) - - assert %DateTime{} = row.received_at - assert row.received_at.time_zone == "Etc/UTC" - end - end - - describe "station_positions/1" do - test "returns the most recent fix per callsign and omits unknown callsigns" do - base = NaiveDateTime.utc_now() - - :ok = - insert_packet( - sender: "TEST-A", - base_callsign: "TEST-A", - lat: 30.0, - lon: -90.0, - received_at: NaiveDateTime.add(base, -3600, :second) - ) - - :ok = - insert_packet( - sender: "TEST-A", - base_callsign: "TEST-A", - lat: 31.0, - lon: -91.0, - received_at: NaiveDateTime.add(base, -1800, :second) - ) - - :ok = - insert_packet( - sender: "TEST-A", - base_callsign: "TEST-A", - lat: 32.5, - lon: -92.5, - received_at: NaiveDateTime.add(base, -60, :second) - ) - - :ok = - insert_packet( - sender: "TEST-B", - base_callsign: "TEST-B", - lat: 40.0, - lon: -100.0, - received_at: NaiveDateTime.add(base, -300, :second) - ) - - result = Aprs.station_positions(["TEST-A", "TEST-B", "TEST-C"]) - - assert result |> Map.keys() |> Enum.sort() == ["TEST-A", "TEST-B"] - - {lat_a, lon_a, heard_a} = result["TEST-A"] - assert_in_delta lat_a, 32.5, 0.0001 - assert_in_delta lon_a, -92.5, 0.0001 - assert %DateTime{} = heard_a - assert heard_a.time_zone == "Etc/UTC" - - {lat_b, lon_b, _heard_b} = result["TEST-B"] - assert_in_delta lat_b, 40.0, 0.0001 - assert_in_delta lon_b, -100.0, 0.0001 - - refute Map.has_key?(result, "TEST-C") - end - - test "empty list short-circuits to %{} without querying" do - # Stop the sandbox owner so a real query would crash with NoConnectionError. - Sandbox.checkin(AprsRepo) - assert Aprs.station_positions([]) == %{} - end - end -end diff --git a/test/mix/tasks/calibrate_aprs_144_test.exs b/test/mix/tasks/calibrate_aprs_144_test.exs deleted file mode 100644 index f71411f9..00000000 --- a/test/mix/tasks/calibrate_aprs_144_test.exs +++ /dev/null @@ -1,49 +0,0 @@ -defmodule Mix.Tasks.Calibrate.Aprs144Test do - @moduledoc """ - Smoke test for the APRS-144 calibration mix task. The task is mostly - orchestration: Aprs query + PathParser + Recalibrator. Each piece has - its own unit tests, so this only confirms the wiring holds together - and that the empty-corpus path exits via the "refusing to fit" branch - rather than crashing. - """ - use Microwaveprop.DataCase, async: false - - alias Ecto.Adapters.SQL.Sandbox - alias Microwaveprop.AprsRepo - alias Mix.Tasks.Calibrate.Aprs144 - - setup do - pid = Sandbox.start_owner!(AprsRepo, shared: true) - on_exit(fn -> Sandbox.stop_owner(pid) end) - - original_shell = Mix.shell() - Mix.shell(Mix.Shell.Process) - on_exit(fn -> Mix.shell(original_shell) end) - - :ok - end - - test "empty aprs DB walks the refuse-to-fit branch without raising" do - output = - ExUnit.CaptureIO.capture_io(fn -> - # Tiny knobs so the task is fast even if it ever did reach training. - Aprs144.run(["--since-hours", "1", "--epochs", "1", "--lr", "0.1", "--max-packets", "10"]) - end) - - # Mix.shell() messages land in the test mailbox; collect them all. - messages = collect_mix_messages() - combined = Enum.join(messages, "\n") <> "\n" <> output - - assert combined =~ "APRS-144 calibration" - assert combined =~ "Pulled 0 packets" - assert combined =~ "Refusing to fit" - end - - defp collect_mix_messages(acc \\ []) do - receive do - {:mix_shell, :info, [msg]} -> collect_mix_messages([msg | acc]) - after - 0 -> Enum.reverse(acc) - end - end -end diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex index a9b2b4d7..cf1f8a53 100644 --- a/test/support/conn_case.ex +++ b/test/support/conn_case.ex @@ -23,6 +23,7 @@ defmodule MicrowavepropWeb.ConnCase do alias Microwaveprop.DataCase alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Weather.GridCache + alias Phoenix.Ecto.SQL.Sandbox using do quote do @@ -39,12 +40,20 @@ defmodule MicrowavepropWeb.ConnCase do end setup tags do - DataCase.setup_sandbox(tags) + owner = DataCase.setup_sandbox(tags) DataCase.reset_score_files() DataCase.stub_nexrad_default() GridCache.clear() ScoreCache.clear() - {:ok, conn: Phoenix.ConnTest.build_conn()} + + # Stamp the sandbox metadata onto the "user-agent" header so a + # LiveView's connected mount (a separate process from this one, per + # `MicrowavepropWeb.SandboxHook`) can see this test's transaction. + metadata = Sandbox.metadata_for(Microwaveprop.Repo, owner) + + conn = Plug.Conn.put_req_header(Phoenix.ConnTest.build_conn(), "user-agent", Sandbox.encode_metadata(metadata)) + + {:ok, conn: conn} end @doc """ diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 7ced5596..350fad3b 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -49,12 +49,31 @@ defmodule Microwaveprop.DataCase do end @doc """ - Sets up the sandbox based on the test tags. + Sets up the sandbox based on the test tags. Returns the owner pid so + callers (e.g. `MicrowavepropWeb.ConnCase`) can build Ecto Sandbox + metadata for out-of-process access (LiveView's connected mount). """ - @spec setup_sandbox(map()) :: :ok + @spec setup_sandbox(map()) :: pid() def setup_sandbox(tags) do - pid = Sandbox.start_owner!(Microwaveprop.Repo, shared: not tags[:async]) - on_exit(fn -> Sandbox.stop_owner(pid) end) + shared? = not tags[:async] + pid = Sandbox.start_owner!(Microwaveprop.Repo, shared: shared?) + + on_exit(fn -> + Sandbox.stop_owner(pid) + + # Ecto reverts the pool to :manual mode when a `{:shared, pid}` + # owner terminates (see Ecto.Adapters.SQL.Sandbox docs). Since + # test_helper.exs relies on global :auto mode so Oban's + # background plugins (Stager, Peers, Met.Reporter) can + # auto-checkout, every `async: false` test permanently flips the + # pool to :manual once it exits — starving those processes of + # connections and crash-looping Oban until it exceeds its + # restart intensity and takes the whole app (and Repo) down with + # it. Restore :auto after any shared owner exits. + if shared?, do: Sandbox.mode(Microwaveprop.Repo, :auto) + end) + + pid end @doc """ diff --git a/test/test_helper.exs b/test/test_helper.exs index 9b61ac53..23d4fb15 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -26,7 +26,7 @@ ExUnit.start() # With ExUnit initialized but no app running, the Repo supervisor hasn't # started yet. Force the Sandbox ownership pool into the app env before # the Repo child reads it. -for repo <- [Microwaveprop.Repo, Microwaveprop.AprsRepo] do +for repo <- [Microwaveprop.Repo] do existing = Application.get_env(:microwaveprop, repo, []) if existing[:url] || existing[:database] do @@ -43,7 +43,21 @@ end # DataCase.setup_sandbox/1 still calls start_owner!/2 for per-test # isolation, which is compatible with :auto mode. Sandbox.mode(Microwaveprop.Repo, :auto) -Sandbox.mode(Microwaveprop.AprsRepo, :auto) + +# lazy_html provides an Enumerable protocol implementation for the +# LazyHTML struct. When protocol consolidation is enabled (the default), +# the consolidated Elixir.Enumerable.beam file is generated during +# `mix compile`. If that consolidation pass runs before lazy_html is on +# the code path, the dispatch table omits LazyHTML — causing every test +# that uses Phoenix.LiveViewTest (which calls Enum.each/2 on LazyHTML +# structs via DOM.parse_document/2) to crash with: +# +# (Protocol.UndefinedError) protocol Enumerable not implemented for +# LazyHTML (a struct) +# +# mix.exs disables `consolidate_protocols` in the test environment so +# the protocol uses dynamic dispatch instead, which always finds the +# Enumerable.LazyHTML implementation at runtime. # Silence sandbox-cleanup disconnect noise. When a test process exits # while still owning a Postgrex connection, the protocol logs an