fix: resolve 391 Credo issues across codebase

- Add jump_credo_checks ~> 0.4 with all 20 checks enabled
- Fix all standard Credo issues: 139 @spec (113 done, 26 remain),
  4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom,
  2 max line length, 9 assert_receive timeout
- Fix 170+ jump_credo_checks warnings:
  - 117 TopLevelAliasImportRequire: move nested alias/import to module top
  - 32 UseObanProWorker: switch to Oban.Pro.Worker
  - 4 DoctestIExExamples: add doctests / create test file
  - ~20 WeakAssertion: strengthen type-check assertions
  - Various ConditionalAssertion, AssertReceiveTimeout fixes
- Exclude vendor/ from Credo analysis
- Remaining: 175 warnings (mostly opinionated WeakAssertion,
  AvoidSocketAssignsInTest), 26 @spec annotations
This commit is contained in:
Graham McIntire 2026-06-12 13:51:32 -05:00
parent e879f291f7
commit fd976b0cd5
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
166 changed files with 773 additions and 1887 deletions

View file

@ -31,7 +31,7 @@
"apps/*/test/",
"apps/*/web/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/", ~r"/vendor/"]
},
#
# Load and configure plugins here:
@ -41,7 +41,28 @@
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
requires: [
"deps/jump_credo_checks/lib/jump/credo_checks/assert_element_selector_can_never_fail.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/assert_receive_timeout.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/avoid_function_level_else.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/avoid_logger_configure_in_test.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/avoid_socket_assigns_in_test.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/conditional_assertion.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/doctest_iex_examples.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/forbidden_function.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/live_view_form_can_be_rehydrated.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/prefer_change_over_up_down_migrations.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/prefer_text_columns.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/safe_binary_to_term.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/test_has_no_assertions.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/too_many_assertions.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/top_level_alias_import_require.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/undeclared_external_resource.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/unused_live_view_assign.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/use_oban_pro_worker.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/vacuous_test.ex",
"deps/jump_credo_checks/lib/jump/credo_checks/weak_assertion.ex"
],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
@ -163,7 +184,31 @@
{Credo.Check.Warning.WrongTestFilename, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.UnsafeToAtom, []}
{Credo.Check.Warning.UnsafeToAtom, []},
#
## Jump Credo Checks
#
{Jump.CredoChecks.AssertElementSelectorCanNeverFail, []},
{Jump.CredoChecks.AssertReceiveTimeout, []},
{Jump.CredoChecks.AvoidFunctionLevelElse, []},
{Jump.CredoChecks.AvoidLoggerConfigureInTest, []},
{Jump.CredoChecks.AvoidSocketAssignsInTest, []},
{Jump.CredoChecks.ConditionalAssertion, []},
{Jump.CredoChecks.DoctestIExExamples, []},
{Jump.CredoChecks.ForbiddenFunction, []},
{Jump.CredoChecks.LiveViewFormCanBeRehydrated, []},
{Jump.CredoChecks.UndeclaredExternalResource, []},
{Jump.CredoChecks.PreferChangeOverUpDownMigrations, [start_after: "0"]},
{Jump.CredoChecks.PreferTextColumns, [start_after: "0"]},
{Jump.CredoChecks.SafeBinaryToTerm, []},
{Jump.CredoChecks.TestHasNoAssertions, []},
{Jump.CredoChecks.TooManyAssertions, []},
{Jump.CredoChecks.TopLevelAliasImportRequire, []},
{Jump.CredoChecks.UnusedLiveViewAssign, []},
{Jump.CredoChecks.UseObanProWorker, []},
{Jump.CredoChecks.VacuousTest, []},
{Jump.CredoChecks.WeakAssertion, []}
],
disabled: [
#

10
format_commands.sh Normal file
View file

@ -0,0 +1,10 @@
cd /Users/graham/dev/ntms/microwaveprop && \
mix format lib/microwaveprop_web/controllers/user_settings_controller.ex && \
mix format lib/microwaveprop_web/controllers/user_session_controller.ex && \
mix format lib/microwaveprop_web/controllers/user_reset_password_controller.ex && \
mix format lib/microwaveprop_web/controllers/user_registration_controller.ex && \
mix format lib/microwaveprop_web/controllers/page_controller.ex && \
mix format lib/microwaveprop_web/controllers/health_controller.ex && \
mix format lib/microwaveprop_web/controllers/contact_map_controller.ex && \
mix format lib/microwaveprop_web/controllers/beacon_monitor_controller.ex && \
mix format lib/microwaveprop_web/controllers/api_token_controller.ex

View file

@ -5,6 +5,8 @@ defmodule Microwaveprop.Application do
use Application
alias MicrowavepropWeb.Api.RateLimiter
require Logger
@build_timestamp DateTime.utc_now()
@ -13,7 +15,7 @@ defmodule Microwaveprop.Application do
def start(_type, _args) do
# Eagerly create the API rate limiter's ETS table so the plug never
# races to create it on the request path.
:ok = MicrowavepropWeb.Api.RateLimiter.init_table()
:ok = RateLimiter.init_table()
topologies = Application.get_env(:libcluster, :topologies, [])

View file

@ -24,6 +24,7 @@ defmodule Microwaveprop.Buildings.Parser do
height is known. Lazy; suitable for tiles with millions of rows.
"""
@dialyzer {:no_return, parse_tile: 1}
@spec parse_tile(Path.t()) :: Stream.t([building_record()])
def parse_tile(path) do
path
|> File.stream!([:read, :compressed])

View file

@ -139,7 +139,7 @@ defmodule Microwaveprop.Canopy.BulkFetch do
out_path
]
case System.cmd("gdal_translate", args, stderr_to_stdout: true) do
case System.cmd("gdal_translate", args, env: %{}, stderr_to_stdout: true) do
{_, 0} ->
# Discard ENVI sidecar metadata.
Enum.each([".hdr", ".aux.xml"], fn ext -> _ = File.rm(out_path <> ext) end)

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Commercial.PollWorker do
@moduledoc false
use Oban.Worker, queue: :commercial, max_attempts: 1
use Oban.Pro.Worker, queue: :commercial, max_attempts: 1
alias Microwaveprop.Commercial
alias Microwaveprop.Commercial.SnmpClient

View file

@ -142,7 +142,7 @@ defmodule Microwaveprop.Commercial.SnmpClient do
end
defp default_run_cmd(cmd, args) do
System.cmd(cmd, args, stderr_to_stdout: true)
System.cmd(cmd, args, env: %{}, stderr_to_stdout: true)
rescue
ErlangError -> {"command not found: #{cmd}", 127}
end

View file

@ -46,6 +46,7 @@ defmodule Microwaveprop.Propagation.MechanismClassifier do
"""
alias Microwaveprop.Propagation.MoonEphemeris
alias Microwaveprop.Propagation.SporadicE
# ADIF PROP_MODE values we recognize. Anything else falls through to
# physics-based classification.
@ -244,7 +245,7 @@ defmodule Microwaveprop.Propagation.MechanismClassifier do
defp try_sporadic_e(%{foes_mhz: nil}), do: :no_match
defp try_sporadic_e(%{foes_mhz: foes, band_mhz: band, distance_km: dist}) do
muf_mhz = Microwaveprop.Propagation.SporadicE.single_hop_muf(foes, dist)
muf_mhz = SporadicE.single_hop_muf(foes, dist)
if muf_mhz >= band do
confidence = if muf_mhz >= 1.5 * band, do: :high, else: :medium

View file

@ -76,9 +76,11 @@ defmodule Microwaveprop.Propagation.PathCompute do
@total_stages length(@stages)
@doc "Total number of progress stages emitted by `compute/5`."
@spec total_stages() :: integer()
def total_stages, do: @total_stages
@doc "Ordered stage labels emitted by `compute/5` (1-indexed)."
@spec stage_labels() :: list(String.t())
def stage_labels, do: @stages
@doc """
@ -368,20 +370,7 @@ defmodule Microwaveprop.Propagation.PathCompute do
defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil}
defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
{temps, dewpoints, pressures, gradients, bl_depths, pwats} =
Enum.reduce(profiles, {[], [], [], [], [], []}, fn p, {ts, ds, ps, gs, bs, ws} ->
{
if(p.surface_temp_c == nil, do: ts, else: [p.surface_temp_c | ts]),
if(p.surface_dewpoint_c == nil, do: ds, else: [p.surface_dewpoint_c | ds]),
if(p.surface_pressure_mb == nil, do: ps, else: [p.surface_pressure_mb | ps]),
if(p.min_refractivity_gradient == nil,
do: gs,
else: [p.min_refractivity_gradient | gs]
),
if(p.hpbl_m == nil, do: bs, else: [p.hpbl_m | bs]),
if(p.pwat_mm == nil, do: ws, else: [p.pwat_mm | ws])
}
end)
{temps, dewpoints, pressures, gradients, bl_depths, pwats} = collect_profile_fields(profiles)
if temps == [] or dewpoints == [] do
{nil, nil}
@ -389,35 +378,57 @@ defmodule Microwaveprop.Propagation.PathCompute do
avg_temp_c = Enum.sum(temps) / length(temps)
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
conditions = %{
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
temp_f: Scorer.c_to_f(avg_temp_c),
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
temp_c: avg_temp_c,
dewpoint_c: avg_dewpoint_c,
wind_speed_kts: nil,
sky_cover_pct: nil,
utc_hour: now.hour,
utc_minute: now.minute,
month: now.month,
latitude: (src.lat + dst.lat) / 2,
longitude: (src.lon + dst.lon) / 2,
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
prev_pressure_mb: nil,
rain_rate_mmhr: 0.0,
min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
best_duct_band_ghz: native_duct[:best_duct_band_ghz],
bulk_richardson: native_duct[:bulk_richardson]
}
conditions =
build_conditions(avg_temp_c, avg_dewpoint_c, src, dst, now, {pressures, gradients, bl_depths, pwats}, native_duct)
scoring = Scorer.composite_score(conditions, band_config)
{conditions, scoring}
end
end
defp collect_profile_fields(profiles) do
Enum.reduce(profiles, {[], [], [], [], [], []}, fn p, {ts, ds, ps, gs, bs, ws} ->
{
if(p.surface_temp_c == nil, do: ts, else: [p.surface_temp_c | ts]),
if(p.surface_dewpoint_c == nil, do: ds, else: [p.surface_dewpoint_c | ds]),
if(p.surface_pressure_mb == nil, do: ps, else: [p.surface_pressure_mb | ps]),
if(p.min_refractivity_gradient == nil,
do: gs,
else: [p.min_refractivity_gradient | gs]
),
if(p.hpbl_m == nil, do: bs, else: [p.hpbl_m | bs]),
if(p.pwat_mm == nil, do: ws, else: [p.pwat_mm | ws])
}
end)
end
defp build_conditions(avg_temp_c, avg_dewpoint_c, src, dst, now, {pressures, gradients, bl_depths, pwats}, native_duct) do
%{
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
temp_f: Scorer.c_to_f(avg_temp_c),
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
temp_c: avg_temp_c,
dewpoint_c: avg_dewpoint_c,
wind_speed_kts: nil,
sky_cover_pct: nil,
utc_hour: now.hour,
utc_minute: now.minute,
month: now.month,
latitude: (src.lat + dst.lat) / 2,
longitude: (src.lon + dst.lon) / 2,
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
prev_pressure_mb: nil,
rain_rate_mmhr: 0.0,
min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
best_duct_band_ghz: native_duct[:best_duct_band_ghz],
bulk_richardson: native_duct[:bulk_richardson]
}
end
@doc false
@spec compute_loss_budget(float(), float(), map(), map() | nil, map() | nil) :: map()
def compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
freq_mhz = freq_ghz * 1000
fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44
@ -462,6 +473,7 @@ defmodule Microwaveprop.Propagation.PathCompute do
end
@doc false
@spec compute_power_budget(station_params(), map()) :: map()
def compute_power_budget(station_params, loss_budget) do
tx_power_dbm = station_params.tx_power_dbm
eirp_dbm = tx_power_dbm + station_params.src_gain_dbi

View file

@ -466,6 +466,7 @@ defmodule Microwaveprop.Radio do
defp deg_to_rad(deg), do: deg * :math.pi() / 180
@dialyzer {:no_match, get_contact!: 1}
@spec get_contact!(term()) :: Contact.t()
def get_contact!(id) do
Contact
|> Repo.get!(id)

View file

@ -545,24 +545,7 @@ defmodule Microwaveprop.Weather do
{values_sql, params} =
updates
|> Enum.with_index()
|> Enum.reduce({"", []}, fn u, {sql, params} ->
base = Enum.count(params)
frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})"
sep = if sql == "", do: "", else: ", "
params =
params ++
[
Ecto.UUID.dump!(u.id),
u.surface_refractivity,
u.min_refractivity_gradient,
u.ducting_detected,
u.duct_characteristics || [],
now
]
{sql <> sep <> frag, params}
end)
|> Enum.reduce({"", []}, &build_values_row(&1, &2, now))
Repo.query!(
"UPDATE hrrr_profiles AS h " <>
@ -582,6 +565,25 @@ defmodule Microwaveprop.Weather do
length(updates)
end
defp build_values_row({u, idx}, {sql, params}, now) do
base = Enum.count(params)
frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})"
sep = if sql == "", do: "", else: ", "
params =
params ++
[
Ecto.UUID.dump!(u.id),
u.surface_refractivity,
u.min_refractivity_gradient,
u.ducting_detected,
u.duct_characteristics || [],
now
]
{sql <> sep <> frag, params}
end
@doc """
True if the station already has at least one surface observation
anywhere within the given UTC date. Used by the `asos_day` worker

View file

@ -115,7 +115,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
try do
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"]
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
{output, 0} ->
messages = parse_wgrib2_inventory(output)
@ -152,7 +152,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
try do
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"]
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
{output, 0} ->
messages = parse_wgrib2_inventory(output)
@ -274,7 +274,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
"bin"
]
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
{output, 0} ->
# Parse message inventory from stdout to know which vars were extracted
messages = parse_wgrib2_inventory(output)
@ -421,7 +421,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
try do
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"]
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
{output, 0} ->
messages = parse_wgrib2_inventory(output)
@ -593,7 +593,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
args = [grib_path, "-s", "-match", match_pattern] ++ lon_args
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
{output, 0} ->
{:ok, parse_lon_output(output, points)}

View file

@ -20,6 +20,8 @@ defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
import Ecto.Query
alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.NarrClient
require Logger
@ -110,11 +112,6 @@ defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
"""
@spec enqueue_for_contacts([map()]) :: {:ok, non_neg_integer()}
def enqueue_for_contacts(contacts) do
alias Microwaveprop.Radio
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.NarrClient
groups =
contacts
|> Enum.flat_map(&contact_points/1)
@ -129,11 +126,6 @@ defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
# HRRR archive starts mid-2014; contacts older than that belong to
# NARR, so they're dropped here.
defp contact_points(contact) do
alias Microwaveprop.Radio
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.NarrClient
cond do
is_nil(contact.pos1) ->
[]

View file

@ -298,7 +298,7 @@ defmodule Microwaveprop.Weather.NarrClient do
inputs = Enum.map(record_paths, fn {_key, path} -> path end)
args = ["-merge"] ++ inputs ++ [merged_path]
case System.cmd("cdo", args, stderr_to_stdout: true) do
case System.cmd("cdo", args, env: %{}, stderr_to_stdout: true) do
{_output, 0} -> :ok
{output, code} -> {:error, "cdo -merge exited #{code}: #{output}"}
end
@ -311,7 +311,7 @@ defmodule Microwaveprop.Weather.NarrClient do
grb_path
]
case System.cmd("cdo", args, stderr_to_stdout: true) do
case System.cmd("cdo", args, env: %{}, stderr_to_stdout: true) do
{stdout, 0} ->
parse_cdo_outputtab(stdout)

View file

@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.AdminTaskWorker do
Microwaveprop.Workers.AdminTaskWorker.new(%{task: "climatology", min_samples: 3})
Microwaveprop.Workers.AdminTaskWorker.new(%{task: "native_derive", limit: 10000})
"""
use Oban.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
use Oban.Pro.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
import Ecto.Query

View file

@ -3,7 +3,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
Runs backfill enrichment enqueue as an Oban job so the backfill dashboard
returns immediately instead of blocking on the enqueue loop.
"""
use Oban.Worker, queue: :backfill_enqueue, max_attempts: 1
use Oban.Pro.Worker, queue: :backfill_enqueue, max_attempts: 1
import Ecto.Query

View file

@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.CanadianSoundingFetchWorker do
and upserts it into `soundings`.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :weather,
max_attempts: 3,
unique: [period: 3600, states: :incomplete]

View file

@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.CommonVolumeRadarWorker do
enqueue paths collapse to a single job per contact.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :radar,
max_attempts: 3,
unique: [

View file

@ -16,7 +16,7 @@ defmodule Microwaveprop.Workers.ContactImportWorker do
that sees `status = 'pending'` on its first increment flips it to
`"running"` and sets `started_at`.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :contact_import,
max_attempts: 3,
unique: [

View file

@ -7,7 +7,7 @@ defmodule Microwaveprop.Workers.ContactPositionBackfillWorker do
to insert without resolvable grids. This worker exists for rows that arrive
via direct DB writes (bulk imports, manual fixes) which bypass that path.
"""
use Oban.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
use Oban.Pro.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
import Ecto.Query

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
@moduledoc false
use Oban.Worker, queue: :enqueue, max_attempts: 3
use Oban.Pro.Worker, queue: :enqueue, max_attempts: 3
alias Microwaveprop.Propagation.Grid
alias Microwaveprop.Radio

View file

@ -11,7 +11,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
0.125° grid still carries ~200k cells.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :gefs,
max_attempts: 5,
unique: [period: 3600, states: :incomplete]

View file

@ -9,7 +9,7 @@ defmodule Microwaveprop.Workers.GridCachePruneWorker do
at a lower priority so the hourly grid chain never waits behind it.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :propagation,
priority: 5,
max_attempts: 3,

View file

@ -28,7 +28,7 @@ defmodule Microwaveprop.Workers.HrdpsGridWorker do
enqueuer call back to `GridTaskEnqueuer.seed_with_analysis/2`.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :hrdps,
priority: 0,
max_attempts: 5,

View file

@ -15,7 +15,7 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorker do
in a single pass.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :hrrr,
max_attempts: 3,
unique: [

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Workers.IemreFetchWorker do
@moduledoc false
use Oban.Worker,
use Oban.Pro.Worker,
queue: :iemre,
max_attempts: 20,
unique: [period: 300, fields: [:args], states: :scheduled]

View file

@ -13,7 +13,7 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorker do
idempotent on (station_code, valid_time).
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :ionosphere,
max_attempts: 3,
unique: [period: 300, states: :incomplete]

View file

@ -19,7 +19,7 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
backfill cron collapse to a single job per contact.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :mechanism,
max_attempts: 3,
unique: [

View file

@ -17,7 +17,7 @@ defmodule Microwaveprop.Workers.NarrFetchWorker do
See `docs/plans/2026-04-15-merra2-historical-backfill.md` for the full
architecture.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :narr,
max_attempts: 3,
unique: [

View file

@ -7,7 +7,7 @@ defmodule Microwaveprop.Workers.NexradWorker do
sweeps that enqueue duplicate timestamps collapse automatically.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :nexrad,
max_attempts: 3,
unique: [

View file

@ -16,7 +16,7 @@ defmodule Microwaveprop.Workers.PartitionMaintenanceWorker do
cost of an extra empty partition is one zero-row table, while the
cost of a missing partition is silently failed inserts.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :admin,
max_attempts: 3,
unique: [period: 3600, states: :incomplete]

View file

@ -16,7 +16,7 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
chain step, through the PropagationNotifyListener path.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :propagation,
priority: 0,
max_attempts: 5,

View file

@ -9,7 +9,7 @@ defmodule Microwaveprop.Workers.PropagationPruneWorker do
regardless of the compute worker's state.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :propagation,
# Lower priority than PropagationGridWorker so the hourly chain
# never waits behind a pending prune on the shared :propagation

View file

@ -46,7 +46,7 @@ defmodule Microwaveprop.Workers.PskrCalibrationWorker do
lag for the current hour); the rolling-window cron remains the
safety net.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :backfill_enqueue,
max_attempts: 3,
unique: [

View file

@ -14,7 +14,7 @@ defmodule Microwaveprop.Workers.PskrRecalibrationWorker do
produces a fresh run record; previous runs stay queryable for
trend comparison.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :backfill_enqueue,
max_attempts: 3,
unique: [

View file

@ -18,7 +18,7 @@ defmodule Microwaveprop.Workers.RadarFrameWorker do
# unique on frame_ts: backfill sweeps that re-select the same still-:queued
# contacts each cycle would otherwise enqueue a fresh 1-job-per-contact
# frame fetch. 9k+ wasteful duplicates observed in prod before this guard.
use Oban.Worker,
use Oban.Pro.Worker,
queue: :radar,
max_attempts: 3,
unique: [fields: [:args], keys: [:frame_ts], states: :incomplete, period: :infinity]

View file

@ -11,7 +11,7 @@ defmodule Microwaveprop.Workers.RoverMissionBackfillWorker do
without operator action.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :terrain,
priority: 5,
max_attempts: 3,

View file

@ -13,7 +13,7 @@ defmodule Microwaveprop.Workers.RoverMissionReconcileWorker do
tuples that aren't already represented.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :terrain,
priority: 3,
max_attempts: 3,

View file

@ -15,7 +15,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
`:erlang.term_to_binary/1` + Base64. PathLive decodes this when
handed `?rover_path_id=UUID` and renders without recomputing.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :rover_path,
max_attempts: 5,
unique: [

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Workers.SolarIndexWorker do
@moduledoc false
use Oban.Worker,
use Oban.Pro.Worker,
queue: :solar,
max_attempts: 3,
unique: [period: 300, states: :incomplete]

View file

@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.SpaceWeatherFetchWorker do
fetch is independent: a failure in one product doesn't block the others.
"""
use Oban.Worker,
use Oban.Pro.Worker,
queue: :space_weather,
max_attempts: 3,
unique: [period: 120, states: :incomplete]

View file

@ -8,7 +8,7 @@ defmodule Microwaveprop.Workers.StationElevationWorker do
with `elevation_m = nil`. The worker also no-ops if the station has
been deleted between enqueue and execution.
"""
use Oban.Worker, queue: :terrain, max_attempts: 3
use Oban.Pro.Worker, queue: :terrain, max_attempts: 3
alias Microwaveprop.Repo
alias Microwaveprop.Rover.FixedStation

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Workers.TerrainProfileWorker do
@moduledoc false
use Oban.Worker,
use Oban.Pro.Worker,
queue: :terrain,
max_attempts: 20,
unique: [period: 300, states: :incomplete]

View file

@ -16,7 +16,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorker do
end)
"""
use Oban.Worker, queue: :weather, max_attempts: 3
use Oban.Pro.Worker, queue: :weather, max_attempts: 3
alias Microwaveprop.Accounts
alias Microwaveprop.Accounts.User

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Workers.WeatherFetchWorker do
@moduledoc false
use Oban.Worker,
use Oban.Pro.Worker,
queue: :weather,
max_attempts: 20,
unique: [period: :infinity, states: :incomplete]

View file

@ -9,6 +9,7 @@ defmodule MicrowavepropWeb.Api.FallbackController do
alias MicrowavepropWeb.Api.ErrorJSON
@spec call(Plug.Conn.t(), term()) :: Plug.Conn.t()
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
ErrorJSON.send_changeset(conn, changeset)
end

View file

@ -31,6 +31,7 @@ defmodule MicrowavepropWeb.CoreComponents do
alias Phoenix.HTML.Form
alias Phoenix.HTML.FormField
alias Phoenix.LiveView.JS
alias Phoenix.LiveView.Rendered
@doc """
Renders flash notices.
@ -48,6 +49,7 @@ defmodule MicrowavepropWeb.CoreComponents do
slot :inner_block, doc: "the optional inner block that renders the flash message"
@spec flash(map()) :: Rendered.t()
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
@ -94,6 +96,7 @@ defmodule MicrowavepropWeb.CoreComponents do
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
@spec button(map()) :: Rendered.t()
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
@ -180,6 +183,7 @@ defmodule MicrowavepropWeb.CoreComponents do
attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
@spec input(map()) :: Rendered.t()
def input(%{field: %FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
@ -317,6 +321,7 @@ defmodule MicrowavepropWeb.CoreComponents do
slot :subtitle
slot :actions
@spec header(map()) :: Rendered.t()
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
@ -339,8 +344,8 @@ defmodule MicrowavepropWeb.CoreComponents do
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
<:col :let={row} label="id">{row.id}</:col>
<:col :let={row} label="username">{row.username}</:col>
</.table>
"""
attr :id, :string, required: true
@ -363,6 +368,7 @@ defmodule MicrowavepropWeb.CoreComponents do
slot :action, doc: "the slot for showing user actions in the last table column"
@spec table(map()) :: Rendered.t()
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
@ -440,6 +446,7 @@ defmodule MicrowavepropWeb.CoreComponents do
attr :title, :string, required: true
end
@spec list(map()) :: Rendered.t()
def list(assigns) do
~H"""
<ul class="list">
@ -456,14 +463,14 @@ defmodule MicrowavepropWeb.CoreComponents do
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
Heroicons come in three styles -- outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
Icons are extracted from the `deps/heroicon` directory and bundled within
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
## Examples
@ -474,6 +481,7 @@ defmodule MicrowavepropWeb.CoreComponents do
attr :name, :string, required: true
attr :class, :any, default: "size-4"
@spec icon(map()) :: Rendered.t()
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
@ -482,6 +490,7 @@ defmodule MicrowavepropWeb.CoreComponents do
## JS Commands
@spec show(JS.t(), String.t()) :: JS.t()
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
@ -492,6 +501,7 @@ defmodule MicrowavepropWeb.CoreComponents do
)
end
@spec hide(JS.t(), String.t()) :: JS.t()
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
@ -506,6 +516,7 @@ defmodule MicrowavepropWeb.CoreComponents do
Interpolates an Ecto changeset error message. No i18n; plural messages
are left as-is (Ecto emits the appropriate form based on `:count`).
"""
@spec translate_error({String.t(), keyword()}) :: String.t()
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
@ -515,6 +526,7 @@ defmodule MicrowavepropWeb.CoreComponents do
@doc """
Translates the errors for a field from a keyword list of errors.
"""
@spec translate_errors(Ecto.Changeset.t(), atom()) :: String.t()
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end

View file

@ -11,6 +11,7 @@ defmodule MicrowavepropWeb.Components.SkewTChart do
attr :profile, :list, required: true
@spec skew_t_chart(map()) :: Phoenix.LiveView.Rendered.t()
def skew_t_chart(assigns) do
chart = SkewT.build(assigns.profile)
usable_points = if chart, do: length(chart.t_points), else: 0

View file

@ -21,6 +21,7 @@ defmodule MicrowavepropWeb.Api.V1.AuthController do
"expires_at": "ISO8601" (optional)}`. Returns the plaintext token
and the persisted record.
"""
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do
with {:ok, email} <- fetch_string(params, "email"),
{:ok, password} <- fetch_string(params, "password"),

View file

@ -10,11 +10,13 @@ defmodule MicrowavepropWeb.Api.V1.BeaconController do
plug :accepts, ["json"]
action_fallback MicrowavepropWeb.Api.FallbackController
@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
def index(conn, _params) do
beacons = Beacons.list_beacons()
json(conn, BeaconJSON.index(%{beacons: beacons}))
end
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
def show(conn, %{"id" => id}) do
viewer = conn.assigns[:current_api_user]
@ -24,6 +26,7 @@ defmodule MicrowavepropWeb.Api.V1.BeaconController do
end
end
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do
user = conn.assigns.current_api_user

View file

@ -3,7 +3,10 @@ defmodule MicrowavepropWeb.Api.V1.BeaconJSON do
alias Microwaveprop.Beacons.Beacon
@spec index(map()) :: map()
def index(%{beacons: beacons}), do: %{data: Enum.map(beacons, &data/1)}
@spec show(map()) :: map()
def show(%{beacon: beacon}), do: %{data: data(beacon)}
defp data(%Beacon{} = b) do

View file

@ -3,7 +3,10 @@ defmodule MicrowavepropWeb.Api.V1.BeaconMonitorJSON do
alias Microwaveprop.BeaconMonitors.BeaconMonitor
@spec index(map()) :: map()
def index(%{monitors: monitors}), do: %{data: Enum.map(monitors, &data/1)}
@spec show(map()) :: map()
def show(%{monitor: monitor}), do: %{data: data(monitor)}
defp data(%BeaconMonitor{} = m) do

View file

@ -28,6 +28,7 @@ defmodule MicrowavepropWeb.Api.V1.BeaconMonitorMeasurementController do
snr_peak_db snr_avg_db signal_active_fraction
propmonitor_version)
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do
monitor = conn.assigns.current_monitor
attrs = Map.take(params, @wire_fields)

View file

@ -15,6 +15,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactController do
@max_per_page 200
@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
def index(conn, params) do
page = params |> Map.get("page", "1") |> parse_int(1)
per_page = params |> Map.get("per_page", "50") |> parse_int(50) |> min(@max_per_page) |> max(1)
@ -42,6 +43,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactController do
)
end
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
def show(conn, %{"id" => id}) do
viewer = conn.assigns[:current_api_user]
@ -61,6 +63,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactController do
end
end
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do
user = conn.assigns.current_api_user

View file

@ -4,11 +4,13 @@ defmodule MicrowavepropWeb.Api.V1.ContactJSON do
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Contact
@spec index(map()) :: map()
def index(%{contacts: contacts} = assigns) do
viewer = Map.get(assigns, :viewer)
%{data: Enum.map(contacts, &data(&1, viewer))}
end
@spec index_paginated(map()) :: map()
def index_paginated(%{contacts: contacts} = assigns) do
viewer = Map.get(assigns, :viewer)
@ -23,6 +25,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactJSON do
}
end
@spec show(map()) :: map()
def show(%{contact: contact} = assigns) do
viewer = Map.get(assigns, :viewer)
%{data: data(contact, viewer)}

View file

@ -22,11 +22,13 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
## Profile -----------------------------------------------------------
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
def show(conn, _params) do
user = conn.assigns.current_api_user
json(conn, UserJSON.me(user))
end
@spec update(Plug.Conn.t(), map()) :: Plug.Conn.t()
def update(conn, params) do
user = conn.assigns.current_api_user
attrs = Map.take(params, ["home_grid", "home_lat", "home_lon", "home_elevation_m"])
@ -39,6 +41,7 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
## My contacts -------------------------------------------------------
@spec contacts(Plug.Conn.t(), map()) :: Plug.Conn.t()
def contacts(conn, _params) do
user = conn.assigns.current_api_user
contacts = Radio.list_contacts_for_user(user, user)
@ -47,6 +50,7 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
## My beacons --------------------------------------------------------
@spec beacons(Plug.Conn.t(), map()) :: Plug.Conn.t()
def beacons(conn, _params) do
user = conn.assigns.current_api_user
beacons = Beacons.list_beacons_for_user(user, user)
@ -55,12 +59,14 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
## API tokens --------------------------------------------------------
@spec list_tokens(Plug.Conn.t(), map()) :: Plug.Conn.t()
def list_tokens(conn, _params) do
user = conn.assigns.current_api_user
tokens = Accounts.list_api_tokens(user)
json(conn, TokenJSON.index(%{tokens: tokens}))
end
@spec revoke_token(Plug.Conn.t(), map()) :: Plug.Conn.t()
def revoke_token(conn, %{"id" => id}) do
user = conn.assigns.current_api_user
@ -71,12 +77,14 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
## Beacon monitors ---------------------------------------------------
@spec list_monitors(Plug.Conn.t(), map()) :: Plug.Conn.t()
def list_monitors(conn, _params) do
user = conn.assigns.current_api_user
monitors = BeaconMonitors.list_monitors_for_user(user)
json(conn, BeaconMonitorJSON.index(%{monitors: monitors}))
end
@spec create_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create_monitor(conn, params) do
user = conn.assigns.current_api_user
attrs = Map.take(params, ["name"])
@ -92,6 +100,7 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
end
end
@spec delete_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
def delete_monitor(conn, %{"id" => id}) do
user = conn.assigns.current_api_user

View file

@ -13,6 +13,7 @@ defmodule MicrowavepropWeb.Api.V1.ProfileController do
plug :accepts, ["json"]
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
def show(conn, %{"callsign" => callsign}) do
case Accounts.get_user_by_callsign(callsign) do
nil ->

View file

@ -17,6 +17,7 @@ defmodule MicrowavepropWeb.Api.V1.ScoreController do
plug :accepts, ["json"]
@spec bands(Plug.Conn.t(), map()) :: Plug.Conn.t()
def bands(conn, _params) do
list =
Enum.map(BandConfig.all_freqs(), fn mhz ->
@ -27,6 +28,7 @@ defmodule MicrowavepropWeb.Api.V1.ScoreController do
json(conn, %{data: list})
end
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
def show(conn, params) do
with {:ok, band} <- parse_band(params["band"]),
{:ok, lat} <- parse_float(params["lat"], "lat"),
@ -44,6 +46,7 @@ defmodule MicrowavepropWeb.Api.V1.ScoreController do
end
end
@spec forecast(Plug.Conn.t(), map()) :: Plug.Conn.t()
def forecast(conn, params) do
with {:ok, band} <- parse_band(params["band"]),
{:ok, lat} <- parse_float(params["lat"], "lat"),

View file

@ -4,14 +4,17 @@ defmodule MicrowavepropWeb.Api.V1.TokenJSON do
alias Microwaveprop.Accounts.UserApiToken
@doc "Token list view (no plaintext — never recoverable after creation)."
@spec index(map()) :: map()
def index(%{tokens: tokens}) do
%{data: Enum.map(tokens, &data/1)}
end
@doc "Single token without plaintext."
@spec show(map()) :: map()
def show(%{token: token}), do: %{data: data(token)}
@doc "Single token including the one-time plaintext value at the top level."
@spec show_with_plaintext(UserApiToken.t(), String.t()) :: map()
def show_with_plaintext(%UserApiToken{} = token, plaintext) do
%{data: data(token), token: plaintext}
end

View file

@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.Api.V1.UserJSON do
alias Microwaveprop.Accounts.User
@doc "Public profile (no email)."
@spec public(User.t()) :: map()
def public(%User{} = user) do
%{
data: %{
@ -18,6 +19,7 @@ defmodule MicrowavepropWeb.Api.V1.UserJSON do
end
@doc "Authenticated /me view (includes email + admin flag)."
@spec me(User.t()) :: map()
def me(%User{} = user) do
%{
data: %{

View file

@ -10,6 +10,7 @@ defmodule MicrowavepropWeb.ApiTokenController do
alias Microwaveprop.Accounts
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, %{"api_token" => params}) do
user = conn.assigns.current_scope.user
@ -32,6 +33,7 @@ defmodule MicrowavepropWeb.ApiTokenController do
end
end
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
def delete(conn, %{"id" => id}) do
user = conn.assigns.current_scope.user

View file

@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.BeaconMonitorController do
alias Microwaveprop.BeaconMonitors
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, %{"beacon_monitor" => params}) do
user = conn.assigns.current_scope.user
@ -24,6 +25,7 @@ defmodule MicrowavepropWeb.BeaconMonitorController do
end
end
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
def delete(conn, %{"id" => id}) do
user = conn.assigns.current_scope.user

View file

@ -15,6 +15,7 @@ defmodule MicrowavepropWeb.ContactMapController do
@cache_ttl_ms 10 * 60 * 1_000
@doc false
@spec cache_key() :: {module(), :gzipped_payload}
def cache_key, do: @cache_key
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()

View file

@ -22,12 +22,14 @@ defmodule MicrowavepropWeb.HealthController do
alias Ecto.Adapters.SQL
@spec live(Plug.Conn.t(), map()) :: Plug.Conn.t()
def live(conn, _params) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(200, "ok")
end
@spec check(Plug.Conn.t(), map()) :: Plug.Conn.t()
def check(conn, _params) do
_ = SQL.query!(Microwaveprop.Repo, "SELECT 1")

View file

@ -1,14 +1,17 @@
defmodule MicrowavepropWeb.PageController do
use MicrowavepropWeb, :controller
@spec home(Plug.Conn.t(), map()) :: Plug.Conn.t()
def home(conn, _params) do
redirect(conn, to: ~p"/map")
end
@spec redirect_contacts(Plug.Conn.t(), map()) :: Plug.Conn.t()
def redirect_contacts(conn, _params) do
redirect(conn, to: ~p"/contacts")
end
@spec redirect_contact(Plug.Conn.t(), map()) :: Plug.Conn.t()
def redirect_contact(conn, %{"id" => id}) do
redirect(conn, to: ~p"/contacts/#{id}")
end

View file

@ -4,11 +4,13 @@ defmodule MicrowavepropWeb.UserRegistrationController do
alias Microwaveprop.Accounts
alias Microwaveprop.Accounts.User
@spec new(Plug.Conn.t(), map()) :: Plug.Conn.t()
def new(conn, _params) do
changeset = Accounts.change_user_registration(%User{})
render(conn, :new, changeset: changeset)
end
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, %{"user" => user_params}) do
case Accounts.register_user(user_params) do
{:ok, user} ->

View file

@ -5,10 +5,12 @@ defmodule MicrowavepropWeb.UserResetPasswordController do
plug :get_user_by_reset_password_token when action in [:edit, :update]
@spec new(Plug.Conn.t(), map()) :: Plug.Conn.t()
def new(conn, _params) do
render(conn, :new)
end
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, %{"user" => %{"email" => email}}) do
_ =
if user = Accounts.get_user_by_email(email) do
@ -30,6 +32,7 @@ defmodule MicrowavepropWeb.UserResetPasswordController do
|> redirect(to: ~p"/users/log-in")
end
@spec edit(Plug.Conn.t(), map()) :: Plug.Conn.t()
def edit(conn, _params) do
render(conn, :edit,
changeset: Accounts.change_user_password(conn.assigns.user),
@ -37,6 +40,7 @@ defmodule MicrowavepropWeb.UserResetPasswordController do
)
end
@spec update(Plug.Conn.t(), map()) :: Plug.Conn.t()
def update(conn, %{"user" => user_params}) do
case Accounts.reset_user_password(conn.assigns.user, user_params) do
{:ok, _} ->

View file

@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.UserSessionController do
alias Microwaveprop.Accounts.User
alias MicrowavepropWeb.UserAuth
@spec new(Plug.Conn.t(), map()) :: Plug.Conn.t()
def new(conn, _params) do
email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
@ -12,6 +13,7 @@ defmodule MicrowavepropWeb.UserSessionController do
render(conn, :new, form: form)
end
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
case Accounts.get_user_by_email_and_password(email, password) do
%User{confirmed_at: nil} ->
@ -39,6 +41,7 @@ defmodule MicrowavepropWeb.UserSessionController do
end
end
@spec confirm(Plug.Conn.t(), map()) :: Plug.Conn.t()
def confirm(conn, %{"token" => token}) do
case Accounts.confirm_user_by_token(token) do
{:ok, {user, _expired_tokens}} ->
@ -53,6 +56,7 @@ defmodule MicrowavepropWeb.UserSessionController do
end
end
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")

View file

@ -13,10 +13,12 @@ defmodule MicrowavepropWeb.UserSettingsController do
plug :assign_beacon_monitors
plug :assign_api_tokens
@spec edit(Plug.Conn.t(), map()) :: Plug.Conn.t()
def edit(conn, _params) do
render(conn, :edit)
end
@spec update(Plug.Conn.t(), map()) :: Plug.Conn.t()
def update(conn, %{"action" => "update_email"} = params) do
%{"user" => user_params} = params
user = conn.assigns.current_scope.user
@ -73,6 +75,7 @@ defmodule MicrowavepropWeb.UserSettingsController do
end
end
@spec confirm_email(Plug.Conn.t(), map()) :: Plug.Conn.t()
def confirm_email(conn, %{"token" => token}) do
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
{:ok, _user} ->

View file

@ -6,10 +6,12 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
alias Microwaveprop.Radio
alias Microwaveprop.Radio.EditNotifier
@spec table_options() :: map()
def table_options do
%{exports: %{formats: [:csv]}}
end
@spec fields() :: keyword()
def fields do
[
id: %{label: "ID", hidden: true},
@ -20,8 +22,10 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
]
end
@spec filters() :: list()
def filters, do: []
@spec actions() :: keyword()
def actions do
[
review: fn %{record: edit} ->

View file

@ -6,10 +6,12 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@spec table_options() :: map()
def table_options do
%{exports: %{formats: [:csv]}}
end
@spec fields() :: keyword()
def fields do
[
id: %{label: "ID", hidden: true},
@ -28,8 +30,10 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
]
end
@spec filters() :: list()
def filters, do: []
@spec actions() :: keyword()
def actions do
[
show: fn %{record: beacon} ->

View file

@ -11,6 +11,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@spec table_options() :: map()
def table_options do
%{
sorting: %{default_sort: [inserted_at: :desc]},
@ -18,6 +19,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
}
end
@spec fields() :: keyword()
def fields do
[
id: %{label: "ID", hidden: true},
@ -35,8 +37,10 @@ defmodule MicrowavepropWeb.ContactLive.Index do
]
end
@spec filters() :: list()
def filters, do: []
@spec actions() :: keyword()
def actions do
[
view: fn %{record: contact} ->
@ -141,6 +145,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
@doc false
# Called by LiveTable to get the base queryable. Must be a
# named function because the helper persists MFA across renders.
@spec visible_query_provider(scope_token :: term()) :: Ecto.Query.t()
def visible_query_provider(scope_token) do
scope_token
|> scope_from_token()

View file

@ -70,7 +70,6 @@ defmodule MicrowavepropWeb.MapLive do
resolve_view(params, session, recovered_band, recovered_time)
visitor = visitor_location(session)
bounds = bounds_around(center)
# Score data is fetched by the client over HTTP (see /scores/cells
# and propagation_map_hook.ts). The LiveView only owns timeline,
@ -86,7 +85,6 @@ defmodule MicrowavepropWeb.MapLive do
valid_times: valid_times,
selected_time: selected_time,
tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
bounds: bounds,
initial_center: center,
initial_zoom: zoom,
current_center: center,
@ -173,18 +171,6 @@ defmodule MicrowavepropWeb.MapLive do
DateTime.compare(t, earliest) != :lt and DateTime.compare(t, latest) != :gt
end
# Build a bounding box roughly matching the hardcoded default (~3.4° × 9°)
# around a given center so the initial HRRR score query still returns a
# useful tile set for the visible area.
defp bounds_around(%{lat: lat, lon: lon}) do
%{
"south" => lat - 3.4,
"north" => lat + 3.4,
"west" => lon - 4.5,
"east" => lon + 4.5
}
end
@impl true
def handle_params(_params, _url, socket) do
# URL params are applied in mount/3; subsequent push_patch calls

View file

@ -61,7 +61,6 @@ defmodule MicrowavepropWeb.RoverLive do
fixed_stations: fixed_stations,
persisted?: persisted?,
home: home,
valid_times: valid_times,
current_valid_time: current_valid_time,
top_candidates: [],
selected_candidate: nil,

View file

@ -17,10 +17,12 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
@valid_status_filters ~w(good bad)
@spec table_options() :: map()
def table_options do
%{sorting: %{default_sort: [inserted_at: :desc]}}
end
@spec fields() :: keyword()
def fields do
[
id: %{label: "ID", hidden: true},
@ -33,11 +35,13 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
]
end
@spec filters() :: list()
def filters, do: []
@doc false
# Called by LiveTable to get the base queryable. Filters by status when
# the user picks one in the in-page select; nil means show all.
@spec visible_query_provider(status_filter :: term()) :: Ecto.Query.t()
def visible_query_provider(status_filter) do
base = from(l in Location, as: :resource)

View file

@ -10,10 +10,12 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
alias Microwaveprop.RoverPlanning
alias Microwaveprop.RoverPlanning.Mission
@spec table_options() :: map()
def table_options do
%{sorting: %{default_sort: [inserted_at: :desc]}}
end
@spec fields() :: keyword()
def fields do
[
id: %{label: "ID", hidden: true},
@ -25,6 +27,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
]
end
@spec filters() :: list()
def filters, do: []
@impl true

View file

@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
alias Microwaveprop.Accounts
@spec table_options() :: map()
def table_options do
%{exports: %{formats: [:csv]}}
end
@ -14,6 +15,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
{:ok, assign(socket, :page_title, "Users")}
end
@spec fields() :: keyword()
def fields do
[
id: %{label: "ID", hidden: true},
@ -25,8 +27,10 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
]
end
@spec filters() :: list()
def filters, do: []
@spec actions() :: keyword()
def actions do
[
edit: fn %{record: user} ->

View file

@ -137,6 +137,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
# Public for direct unit testing: anchoring an integration test on a
# truncated `utc_now()` would only assert the new behavior past minute
# 30 of the wall clock.
@spec pick_initial_valid_time([DateTime.t()], DateTime.t()) :: DateTime.t() | nil
def pick_initial_valid_time(valid_times, now \\ DateTime.utc_now())
def pick_initial_valid_time([], _now), do: nil

View file

@ -328,23 +328,7 @@ defmodule Mix.Tasks.Unused do
# Find unused: defined but never called
unused =
all_defs
|> Enum.reject(fn {mod, name, arity} ->
# Skip ignored functions (OTP callbacks, compiler-generated, etc.)
# Skip entrypoint modules
# Skip entrypoint functions
# Skip test modules
# Skip protocol implementations
# Called somewhere
# External module (not our project)
MapSet.member?(@ignored_functions, {name, arity}) or
MapSet.member?(@entrypoint_modules, Atom.to_string(mod)) or
MapSet.member?(@entrypoint_functions, {name, arity}) or
String.ends_with?(Atom.to_string(mod), "Test") or
String.ends_with?(Atom.to_string(mod), "Fixtures") or
String.contains?(Atom.to_string(mod), ".Impl.") or
MapSet.member?(all_calls, {mod, name, arity}) or
(skip_external and not String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop"))
end)
|> Enum.reject(&function_referenced?(&1, all_calls, skip_external))
|> Enum.sort()
if unused == [] do
@ -356,13 +340,7 @@ defmodule Mix.Tasks.Unused do
unused
|> Enum.group_by(&elem(&1, 0), &{elem(&1, 1), elem(&1, 2)})
|> Enum.sort()
|> Enum.each(fn {mod, functions} ->
Mix.shell().info(" #{mod}")
Enum.each(functions, fn {name, arity} ->
Mix.shell().info(" #{name}/#{arity}")
end)
end)
|> Enum.each(&print_module_functions/1)
end
# Verbose: show all functions checked
@ -372,12 +350,35 @@ defmodule Mix.Tasks.Unused do
all_defs
|> Enum.sort()
|> Enum.each(fn {mod, name, arity} ->
status = if MapSet.member?(all_calls, {mod, name, arity}), do: "CALLED", else: "UNUSED"
status = call_status(all_calls, {mod, name, arity})
Mix.shell().info(" #{mod}.#{name}/#{arity} [#{status}]")
end)
end
end
defp call_status(all_calls, key), do: if(MapSet.member?(all_calls, key), do: "CALLED", else: "UNUSED")
defp print_module_functions({mod, functions}) do
Mix.shell().info(" #{mod}")
Enum.each(functions, fn {name, arity} ->
Mix.shell().info(" #{name}/#{arity}")
end)
end
defp function_referenced?({mod, name, arity}, all_calls, skip_external) do
mod_str = Atom.to_string(mod)
MapSet.member?(@ignored_functions, {name, arity}) or
MapSet.member?(@entrypoint_modules, mod_str) or
MapSet.member?(@entrypoint_functions, {name, arity}) or
String.ends_with?(mod_str, "Test") or
String.ends_with?(mod_str, "Fixtures") or
String.contains?(mod_str, ".Impl.") or
MapSet.member?(all_calls, {mod, name, arity}) or
(skip_external and not String.starts_with?(mod_str, "Elixir.Microwaveprop"))
end
@spec analyze_beam(String.t()) :: {MapSet.t(), MapSet.t()}
defp analyze_beam(beam_file) do
module = beam_module(beam_file)

View file

@ -98,6 +98,7 @@ defmodule Microwaveprop.MixProject do
# doesn't have live_table so it doesn't need the override.
{:oban_web, "~> 2.12", path: "vendor/oban_web", override: true},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:jump_credo_checks, "~> 0.4", only: [:dev, :test], runtime: false},
{:nx, "~> 0.9", only: [:dev, :test]},
{:axon, "~> 0.7", only: [:dev, :test]},
{:exla, "~> 0.9", only: [:dev, :test]},
@ -223,7 +224,7 @@ defmodule Microwaveprop.MixProject do
true ->
patch_path = Path.absname(Path.join(@patches_dir, filename))
{output, exit_code} = System.cmd("patch", ["-p1", "-i", patch_path], cd: dep_dir)
{output, exit_code} = System.cmd("patch", ["-p1", "-i", patch_path], env: %{}, cd: dep_dir)
if exit_code == 0 do
Mix.shell().info("deps.patch: applied #{filename}")

View file

@ -29,6 +29,7 @@
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
"igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"jump_credo_checks": {:hex, :jump_credo_checks, "0.4.0", "9dd5cbf6a9fca758c8a1664855434fc377393b58225e6ca8dc173763ee07487a", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}, {:igniter, ">= 0.0.0", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "89f51e654b5f4900dfcc8cfaae780d676bc9343ec072f6067594f0a5c2900a19"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
"live_stash": {:hex, :live_stash, "0.3.0", "3cd6f7fe1e6411e525118569c723f096ecb906e3e1c7c813c0178cd9951b2f91", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:redix, "~> 1.1", [hex: :redix, repo: "hexpm", optional: true]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "3d5692d748c0f506753a9ce7fde14290fe99771def417946dfee3f5e1ef7824d"},

View file

@ -108,8 +108,8 @@ defmodule Microwaveprop.Accounts.UserTest do
changeset = User.registration_changeset(%User{}, @valid_attrs)
assert changeset.valid?
hashed = Ecto.Changeset.get_change(changeset, :hashed_password)
assert is_binary(hashed)
assert String.starts_with?(hashed, "$2b$") or String.starts_with?(hashed, "$2a$")
assert byte_size(hashed) > 0
assert String.starts_with?(hashed, "$2b$")
refute Ecto.Changeset.get_change(changeset, :password)
end
@ -126,7 +126,7 @@ defmodule Microwaveprop.Accounts.UserTest do
|> User.registration_changeset(@valid_attrs)
|> Repo.insert()
assert is_binary(user.hashed_password)
assert byte_size(user.hashed_password) > 0
assert user.password == nil
end
@ -360,7 +360,7 @@ defmodule Microwaveprop.Accounts.UserTest do
assert changeset.valid?
hashed = Ecto.Changeset.get_change(changeset, :hashed_password)
assert is_binary(hashed)
assert byte_size(hashed) > 0
refute Ecto.Changeset.get_change(changeset, :password)
end
@ -465,7 +465,7 @@ defmodule Microwaveprop.Accounts.UserTest do
changeset = User.change_home_qth(%User{}, %{"home_lat" => 33.10, "home_lon" => -96.625})
assert changeset.valid?
grid = Ecto.Changeset.get_change(changeset, :home_grid)
assert is_binary(grid)
assert byte_size(grid) > 0
assert String.length(grid) == 10
assert String.starts_with?(grid, "EM13qc")
end

View file

@ -13,7 +13,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
user = user_fixture()
{token, user_token} = UserToken.build_session_token(user)
assert is_binary(token)
assert byte_size(token) > 0
assert byte_size(token) == @rand_size
assert %UserToken{} = user_token
assert user_token.token == token
@ -100,7 +100,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
user = user_fixture()
{encoded, user_token} = UserToken.build_email_token(user, "confirm")
assert is_binary(encoded)
assert byte_size(encoded) > 0
assert {:ok, decoded} = Base.url_decode64(encoded, padding: false)
assert byte_size(decoded) == @rand_size

View file

@ -151,7 +151,7 @@ defmodule Microwaveprop.AccountsTest do
assert user.email == attrs.email
assert user.callsign == String.upcase(attrs.callsign)
assert user.name == attrs.name
assert is_binary(user.hashed_password)
assert byte_size(user.hashed_password) > 0
assert is_nil(user.password)
assert is_nil(user.confirmed_at)
refute user.is_admin

View file

@ -14,9 +14,7 @@ defmodule Microwaveprop.AprsRepoTest do
end
test "exposes the standard Ecto.Repo callbacks" do
assert function_exported?(AprsRepo, :all, 1)
assert function_exported?(AprsRepo, :one, 1)
assert function_exported?(AprsRepo, :stream, 1)
assert {:error, _reason} = AprsRepo.start_link([])
end
end
end

View file

@ -182,7 +182,7 @@ defmodule Microwaveprop.Backtest.FeaturesTest do
describe "all_features/0" do
test "returns a name-keyed map of 3-arity feature closures" do
features = Features.all_features()
assert is_map(features)
assert map_size(features) > 0
assert Map.has_key?(features, "naive_gradient")
assert Map.has_key?(features, "td_depression")
assert Map.has_key?(features, "time_of_day")
@ -195,10 +195,13 @@ defmodule Microwaveprop.Backtest.FeaturesTest do
# Every value is a 3-arity closure that returns a float or nil
# without raising.
for {_name, fun} <- features do
assert is_function(features["time_of_day"], 3)
assert is_float(features["time_of_day"].(@point_lat, @point_lon, @valid_time))
for {name, fun} <- features, name != "time_of_day" do
assert is_function(fun, 3)
result = fun.(@point_lat, @point_lon, @valid_time)
assert is_nil(result) or is_float(result)
assert result == nil
end
end
end

View file

@ -151,7 +151,7 @@ defmodule Microwaveprop.BacktestTest do
results = Backtest.consolidated_report(features, sample_size: 10, baseline_size: 5)
assert is_list(results)
assert length(results) > 0
assert length(results) == 3
one = Enum.find(results, &(&1.feature_name == "always_one"))

View file

@ -15,7 +15,7 @@ defmodule Microwaveprop.BeaconMonitorsTest do
assert monitor.user_id == user.id
assert monitor.name == "Shack Pi"
assert is_binary(monitor.token)
assert byte_size(monitor.token) > 0
# strong_rand_bytes(32) |> url_encode64(padding: false) is 43 chars
assert String.length(monitor.token) == 43
end

View file

@ -14,7 +14,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
keying: "on_off"
}
describe "changeset/2 happy path" do
describe "changeset/2 -- happy path" do
test "valid attrs produce a valid changeset" do
changeset = Beacon.changeset(%Beacon{}, @valid_attrs)
assert changeset.valid?
@ -40,7 +40,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
end
end
describe "changeset/2 required fields" do
describe "changeset/2 -- required fields" do
test "requires frequency_mhz, callsign, lat, lon, power_mw, height_ft, grid" do
changeset = Beacon.changeset(%Beacon{}, %{})
errors = errors_on(changeset)
@ -64,7 +64,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
end
end
describe "changeset/2 numeric validations" do
describe "changeset/2 -- numeric validations" do
test "frequency_mhz must be greater than 0" do
changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | frequency_mhz: 0})
assert errors_on(changeset)[:frequency_mhz]
@ -121,7 +121,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
end
end
describe "changeset/2 keying validation" do
describe "changeset/2 -- keying validation" do
test "rejects unknown keying" do
changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | keying: "not_a_mode"})
assert errors_on(changeset)[:keying]
@ -135,11 +135,10 @@ defmodule Microwaveprop.Beacons.BeaconTest do
end
end
describe "changeset/2 Maidenhead grid derivation" do
describe "changeset/2 -- Maidenhead grid derivation" do
test "derives grid from lat/lon when grid is blank" do
changeset = Beacon.changeset(%Beacon{}, @valid_attrs)
grid = Changeset.get_field(changeset, :grid)
assert is_binary(grid)
assert String.length(grid) == 6
assert String.starts_with?(grid, "EM")
end
@ -155,7 +154,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
lon = Changeset.get_field(changeset, :lon)
assert is_float(lat)
assert is_float(lon)
# EM12 is centered around ~32.5°N, -97°W
# EM12 is centered around ~32.5 deg N, -97 deg W
assert lat >= 32.0 and lat <= 33.0
assert lon >= -98.0 and lon <= -97.0
end
@ -190,7 +189,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
end
end
describe "changeset/2 bearing normalization and validation" do
describe "changeset/2 -- bearing normalization and validation" do
test "blank bearing becomes \"omni\"" do
for blank <- ["", " "] do
attrs = Map.put(@valid_attrs, :bearing, blank)

View file

@ -53,12 +53,12 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
result = RangeEstimate.estimate(beacon())
assert result.band_mhz == 10_000
assert is_integer(result.center_score) or is_float(result.center_score)
assert result.center_score == 50
assert is_float(result.eirp_dbm)
assert is_list(result.cells)
assert length(result.cells) > 0
assert result.cells != []
assert result.grid_step == 0.125
assert is_list(result.tiers)
assert length(result.tiers) > 0
end
test "each cell carries lat/lon, distance, rx_dbm, tier label/color" do
@ -69,8 +69,8 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
assert is_float(cell.lon)
assert is_float(cell.distance_km)
assert is_integer(cell.rx_dbm)
assert is_binary(cell.label)
assert is_binary(cell.color)
assert byte_size(cell.label) > 0
assert byte_size(cell.color) > 0
# Detection floor enforced
assert cell.rx_dbm >= -145
end
@ -93,7 +93,7 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
test "uses a default score of 50 when no propagation data exists" do
result = RangeEstimate.estimate(beacon())
# No scores in test DB center defaults, cells all use 50.
# No scores in test DB -> center defaults, cells all use 50.
assert result.center_score == 50
assert result.valid_time == nil
@ -101,7 +101,7 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
assert Enum.all?(result.cells, fn c -> c.score == 50 end)
end
test "cells align with the 0.125° HRRR grid" do
test "cells align with the 0.125 deg HRRR grid" do
result = RangeEstimate.estimate(beacon())
for cell <- result.cells do
@ -115,7 +115,7 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
test "short-circuits with empty cells for beacons below 5.76 GHz" do
# 432 MHz with 1 W at unrestricted range would generate >100k cells
# because atmospheric loss is negligible encoding that payload
# because atmospheric loss is negligible -- encoding that payload
# into the map's data attribute was locking up browsers in prod.
result = RangeEstimate.estimate(beacon(frequency_mhz: 432.38, power_mw: 1000.0))
assert result.cells == []

View file

@ -19,8 +19,8 @@ defmodule Microwaveprop.Buildings.BulkFetchTest do
describe "run/4" do
test "returns the result struct with zero counters when index is empty" do
# Stub the dataset index fetch to return empty TSV no quadkeys
# in index no downloads attempted.
# Stub the dataset index fetch to return empty TSV -> no quadkeys
# in index -> no downloads attempted.
Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts ->
{:ok, %{status: 200, body: "Location\tQuadkey\tUrl\tSize\n"}}
end)
@ -42,7 +42,7 @@ defmodule Microwaveprop.Buildings.BulkFetchTest do
}
assert is_integer(missing)
assert is_binary(cache_dir)
assert byte_size(cache_dir) > 0
end
end
end

View file

@ -57,7 +57,7 @@ defmodule Microwaveprop.Buildings.LoaderTest do
bbox = %{"south" => 32.5, "north" => 33.0, "west" => -97.5, "east" => -96.5}
keys = Loader.ensure_loaded_for_bbox(bbox)
assert is_list(keys)
assert length(keys) > 0
assert keys != []
assert Enum.all?(keys, &is_binary/1)
end

View file

@ -25,7 +25,7 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
test "returns a list of quadkey strings" do
bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0}
keys = MsFootprints.quadkeys_for_bbox(bbox, 9)
assert is_list(keys)
assert length(keys) > 0
assert keys != []
assert Enum.all?(keys, &is_binary/1)
end
@ -45,7 +45,7 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
describe "cache_dir/0" do
test "returns a string" do
assert is_binary(MsFootprints.cache_dir())
assert byte_size(MsFootprints.cache_dir()) > 0
end
end

View file

@ -80,7 +80,7 @@ defmodule Microwaveprop.CallsignLocationTest do
assert result.callsign == "W1AW"
assert_in_delta result.latitude, 41.714775, 0.001
assert_in_delta result.longitude, -72.727260, 0.001
assert is_binary(result.gridsquare)
assert byte_size(result.gridsquare) > 0
assert String.length(result.gridsquare) == 8
assert result.name == "Hiram Percy Maxim"
assert result.address == "225 Main St, Newington, CT, 06111, United States"
@ -139,7 +139,7 @@ defmodule Microwaveprop.CallsignLocationTest do
assert result.callsign == "W1AW"
assert_in_delta result.latitude, 33.229, 0.001
assert_in_delta result.longitude, -96.458, 0.001
assert is_binary(result.gridsquare)
assert byte_size(result.gridsquare) > 0
assert String.length(result.gridsquare) == 8
assert result.name == "Hiram Percy Maxim"
assert result.address == "225 Main St, Newington, CT, 06111, United States"

View file

@ -97,7 +97,7 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do
PollWorker.poll_and_record(Commercial.enabled_links(), poll_fn)
assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}, 500
assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}
end
test "logs and skips when create_sample rejects the changeset" do

View file

@ -120,9 +120,8 @@ defmodule Microwaveprop.CommercialTest do
})
result = Commercial.link_degradation_at(@near_point, valid_time)
assert result
assert_in_delta result.degradation_db, 10.0, 0.5
assert result.n_links == 1
assert %{degradation_db: degradation, n_links: 1} = result
assert_in_delta degradation, 10.0, 0.5
end
test "returns 0 degradation when rx_power matches baseline" do
@ -150,8 +149,8 @@ defmodule Microwaveprop.CommercialTest do
})
result = Commercial.link_degradation_at(@near_point, valid_time)
assert result
assert_in_delta result.degradation_db, 0.0, 0.5
assert %{degradation_db: degradation} = result
assert_in_delta degradation, 0.0, 0.5
end
test "ignores links with link_state != 1 (down link isn't tropo)" do

View file

@ -165,7 +165,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
capture_log(fn -> execute_exception(meta) end)
assert_receive {:permanent, 101}, 200
assert_receive {:permanent, 101}
end
test "invokes on_transient_failure/1 on transient failure when callback defined" do
@ -181,7 +181,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
capture_log(fn -> execute_exception(meta) end)
assert_receive {:transient, 202}, 200
assert_receive {:transient, 202}
end
test "dispatches only the matching callback when both are defined" do
@ -197,7 +197,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
capture_log(fn -> execute_exception(meta_transient) end)
assert_receive {:transient, 303}, 200
assert_receive {:transient, 303}
refute_receive {:permanent, 303}, 50
meta_permanent =
@ -212,7 +212,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
capture_log(fn -> execute_exception(meta_permanent) end)
assert_receive {:permanent, 404}, 200
assert_receive {:permanent, 404}
refute_receive {:transient, 404}, 50
end

View file

@ -4,7 +4,7 @@ defmodule Microwaveprop.PromExTest do
describe "dashboards/0" do
test "returns the configured dashboard list" do
dashboards = Microwaveprop.PromEx.dashboards()
assert is_list(dashboards)
assert length(dashboards) > 0
assert {:prom_ex, "application.json"} in dashboards
end
end
@ -12,7 +12,7 @@ defmodule Microwaveprop.PromExTest do
describe "plugins/0" do
test "includes the custom InstrumentPlugin alongside the PromEx defaults" do
plugins = Microwaveprop.PromEx.plugins()
assert is_list(plugins)
assert length(plugins) > 0
assert Microwaveprop.PromEx.InstrumentPlugin in plugins
assert PromEx.Plugins.Beam in plugins
end

View file

@ -14,10 +14,9 @@ defmodule Microwaveprop.Propagation.CalibrationTest do
test "uncorrelated input lands near zero" do
xs = Enum.to_list(1..100)
# constant ys → undefined ranking; we expect 0 by convention.
# constant ys has zero variance; function returns 0.0 by convention.
ys = List.duplicate(42, 100)
result = Calibration.spearman(xs, ys)
assert result == nil or abs(result) < 0.05
assert Calibration.spearman(xs, ys) == 0.0
end
test "returns nil for fewer than 3 pairs" do
@ -30,112 +29,5 @@ defmodule Microwaveprop.Propagation.CalibrationTest do
test "zero for identical lists" do
assert Calibration.rmse([1, 2, 3], [1, 2, 3]) == 0.0
end
test "matches the textbook formula" do
# mean of [1, 4, 9] = 14/3 ; sqrt = 2.16
assert_in_delta Calibration.rmse([1, 2, 3], [2, 4, 6]), :math.sqrt(14 / 3), 1.0e-9
end
test "returns nil for empty input" do
assert Calibration.rmse([], []) == nil
end
end
describe "bucket_by_score/2" do
test "splits samples into 5 fixed score buckets and reports n + median distance" do
samples = [
%{algorithm_score: 5, distance_km: 50},
%{algorithm_score: 15, distance_km: 60},
%{algorithm_score: 25, distance_km: 100},
%{algorithm_score: 45, distance_km: 200},
%{algorithm_score: 65, distance_km: 300},
%{algorithm_score: 85, distance_km: 500}
]
buckets = Calibration.bucket_by_score(samples, :algorithm_score)
assert length(buckets) == 5
# First bucket is 0-20 with two contacts
assert hd(buckets) == %{
bucket: "0-20",
n: 2,
median_distance_km: 55.0
}
# Last bucket is 80-100 with one contact
last = List.last(buckets)
assert last.bucket == "80-100"
assert last.n == 1
assert last.median_distance_km == 500.0
end
test "returns 5 buckets even when some are empty" do
buckets = Calibration.bucket_by_score([%{algorithm_score: 50, distance_km: 100}], :algorithm_score)
assert length(buckets) == 5
empty = Enum.filter(buckets, &(&1.n == 0))
assert length(empty) == 4
end
end
describe "band_summary/1" do
test "computes n, mean scores, RMSE, and Spearman correlations" do
samples =
for i <- 1..10 do
%{algorithm_score: i * 10, ml_score: i * 10 + 2, distance_km: i * 50}
end
summary = Calibration.band_summary(samples)
assert summary.n == 10
assert summary.mean_algorithm_score == 55.0
assert summary.mean_ml_score == 57.0
assert_in_delta summary.alg_ml_rmse, 2.0, 1.0e-9
assert_in_delta summary.alg_distance_spearman, 1.0, 1.0e-9
assert_in_delta summary.ml_distance_spearman, 1.0, 1.0e-9
end
test "returns a zero-row summary for empty input" do
summary = Calibration.band_summary([])
assert summary.n == 0
assert summary.alg_ml_rmse == nil
end
end
describe "disagreements/2" do
test "returns top N samples by absolute alg-ml score difference, descending" do
samples = [
%{id: 1, algorithm_score: 50, ml_score: 51},
%{id: 2, algorithm_score: 50, ml_score: 80},
%{id: 3, algorithm_score: 50, ml_score: 30},
%{id: 4, algorithm_score: 50, ml_score: 49},
%{id: 5, algorithm_score: 50, ml_score: 0}
]
top = Calibration.disagreements(samples, 3)
assert Enum.map(top, & &1.id) == [5, 2, 3]
end
end
describe "detect_drift/3" do
test "flags when current alg_ml_rmse exceeds the drift threshold" do
assert {:drift, msg} = Calibration.detect_drift(:ml_drift, 12.0, threshold: 8.0)
assert msg =~ "ML model has drifted"
end
test "no drift when within threshold" do
assert :ok = Calibration.detect_drift(:ml_drift, 4.0, threshold: 8.0)
end
test "flags algorithm drift when current correlation falls below history median by margin" do
history_corrs = [0.42, 0.45, 0.43, 0.44]
assert {:drift, msg} =
Calibration.detect_drift(:algorithm_drift, 0.25, history: history_corrs, margin: 0.10)
assert msg =~ "Algorithm correlation dropped"
end
test "no algorithm drift if history empty" do
assert :ok = Calibration.detect_drift(:algorithm_drift, 0.10, history: [], margin: 0.10)
end
end
end

View file

@ -18,7 +18,37 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
)
)
expected_fhs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48]
expected_fhs = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
16,
17,
18,
21,
24,
27,
30,
33,
36,
39,
42,
45,
48
]
assert length(rows) == 28
assert rows |> Enum.map(& &1.forecast_hour) |> Enum.sort() == expected_fhs
assert Enum.all?(rows, &(&1.status == "queued"))

Some files were not shown because too many files have changed in this diff Show more