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/*/test/",
"apps/*/web/" "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: # Load and configure plugins here:
@ -41,7 +41,28 @@
# If you create your own checks, you must specify the source files for # 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. # 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 # If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below: # experience, you can change `strict` to `true` below:
@ -163,7 +184,31 @@
{Credo.Check.Warning.WrongTestFilename, []}, {Credo.Check.Warning.WrongTestFilename, []},
{Credo.Check.Readability.Specs, []}, {Credo.Check.Readability.Specs, []},
{Credo.Check.Warning.LeakyEnvironment, []}, {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: [ 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 use Application
alias MicrowavepropWeb.Api.RateLimiter
require Logger require Logger
@build_timestamp DateTime.utc_now() @build_timestamp DateTime.utc_now()
@ -13,7 +15,7 @@ defmodule Microwaveprop.Application do
def start(_type, _args) do def start(_type, _args) do
# Eagerly create the API rate limiter's ETS table so the plug never # Eagerly create the API rate limiter's ETS table so the plug never
# races to create it on the request path. # races to create it on the request path.
:ok = MicrowavepropWeb.Api.RateLimiter.init_table() :ok = RateLimiter.init_table()
topologies = Application.get_env(:libcluster, :topologies, []) 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. height is known. Lazy; suitable for tiles with millions of rows.
""" """
@dialyzer {:no_return, parse_tile: 1} @dialyzer {:no_return, parse_tile: 1}
@spec parse_tile(Path.t()) :: Stream.t([building_record()])
def parse_tile(path) do def parse_tile(path) do
path path
|> File.stream!([:read, :compressed]) |> File.stream!([:read, :compressed])

View file

@ -139,7 +139,7 @@ defmodule Microwaveprop.Canopy.BulkFetch do
out_path 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} -> {_, 0} ->
# Discard ENVI sidecar metadata. # Discard ENVI sidecar metadata.
Enum.each([".hdr", ".aux.xml"], fn ext -> _ = File.rm(out_path <> ext) end) Enum.each([".hdr", ".aux.xml"], fn ext -> _ = File.rm(out_path <> ext) end)

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Commercial.PollWorker do defmodule Microwaveprop.Commercial.PollWorker do
@moduledoc false @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
alias Microwaveprop.Commercial.SnmpClient alias Microwaveprop.Commercial.SnmpClient

View file

@ -142,7 +142,7 @@ defmodule Microwaveprop.Commercial.SnmpClient do
end end
defp default_run_cmd(cmd, args) do 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 rescue
ErlangError -> {"command not found: #{cmd}", 127} ErlangError -> {"command not found: #{cmd}", 127}
end end

View file

@ -46,6 +46,7 @@ defmodule Microwaveprop.Propagation.MechanismClassifier do
""" """
alias Microwaveprop.Propagation.MoonEphemeris alias Microwaveprop.Propagation.MoonEphemeris
alias Microwaveprop.Propagation.SporadicE
# ADIF PROP_MODE values we recognize. Anything else falls through to # ADIF PROP_MODE values we recognize. Anything else falls through to
# physics-based classification. # 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: nil}), do: :no_match
defp try_sporadic_e(%{foes_mhz: foes, band_mhz: band, distance_km: dist}) do 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 if muf_mhz >= band do
confidence = if muf_mhz >= 1.5 * band, do: :high, else: :medium 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) @total_stages length(@stages)
@doc "Total number of progress stages emitted by `compute/5`." @doc "Total number of progress stages emitted by `compute/5`."
@spec total_stages() :: integer()
def total_stages, do: @total_stages def total_stages, do: @total_stages
@doc "Ordered stage labels emitted by `compute/5` (1-indexed)." @doc "Ordered stage labels emitted by `compute/5` (1-indexed)."
@spec stage_labels() :: list(String.t())
def stage_labels, do: @stages def stage_labels, do: @stages
@doc """ @doc """
@ -368,7 +370,23 @@ defmodule Microwaveprop.Propagation.PathCompute do
defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil} 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 defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
{temps, dewpoints, pressures, gradients, bl_depths, pwats} = {temps, dewpoints, pressures, gradients, bl_depths, pwats} = collect_profile_fields(profiles)
if temps == [] or dewpoints == [] do
{nil, nil}
else
avg_temp_c = Enum.sum(temps) / length(temps)
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
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} -> 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_temp_c == nil, do: ts, else: [p.surface_temp_c | ts]),
@ -382,14 +400,10 @@ defmodule Microwaveprop.Propagation.PathCompute do
if(p.pwat_mm == nil, do: ws, else: [p.pwat_mm | ws]) if(p.pwat_mm == nil, do: ws, else: [p.pwat_mm | ws])
} }
end) end)
end
if temps == [] or dewpoints == [] do defp build_conditions(avg_temp_c, avg_dewpoint_c, src, dst, now, {pressures, gradients, bl_depths, pwats}, native_duct) do
{nil, nil} %{
else
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), abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
temp_f: Scorer.c_to_f(avg_temp_c), temp_f: Scorer.c_to_f(avg_temp_c),
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c), dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
@ -411,13 +425,10 @@ defmodule Microwaveprop.Propagation.PathCompute do
best_duct_band_ghz: native_duct[:best_duct_band_ghz], best_duct_band_ghz: native_duct[:best_duct_band_ghz],
bulk_richardson: native_duct[:bulk_richardson] bulk_richardson: native_duct[:bulk_richardson]
} }
scoring = Scorer.composite_score(conditions, band_config)
{conditions, scoring}
end
end end
@doc false @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 def compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
freq_mhz = freq_ghz * 1000 freq_mhz = freq_ghz * 1000
fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44 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 end
@doc false @doc false
@spec compute_power_budget(station_params(), map()) :: map()
def compute_power_budget(station_params, loss_budget) do def compute_power_budget(station_params, loss_budget) do
tx_power_dbm = station_params.tx_power_dbm tx_power_dbm = station_params.tx_power_dbm
eirp_dbm = tx_power_dbm + station_params.src_gain_dbi 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 defp deg_to_rad(deg), do: deg * :math.pi() / 180
@dialyzer {:no_match, get_contact!: 1} @dialyzer {:no_match, get_contact!: 1}
@spec get_contact!(term()) :: Contact.t()
def get_contact!(id) do def get_contact!(id) do
Contact Contact
|> Repo.get!(id) |> Repo.get!(id)

View file

@ -545,24 +545,7 @@ defmodule Microwaveprop.Weather do
{values_sql, params} = {values_sql, params} =
updates updates
|> Enum.with_index() |> Enum.with_index()
|> Enum.reduce({"", []}, fn u, {sql, params} -> |> Enum.reduce({"", []}, &build_values_row(&1, &2, now))
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)
Repo.query!( Repo.query!(
"UPDATE hrrr_profiles AS h " <> "UPDATE hrrr_profiles AS h " <>
@ -582,6 +565,25 @@ defmodule Microwaveprop.Weather do
length(updates) length(updates)
end 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 """ @doc """
True if the station already has at least one surface observation True if the station already has at least one surface observation
anywhere within the given UTC date. Used by the `asos_day` worker 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 try do
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"] 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} -> {output, 0} ->
messages = parse_wgrib2_inventory(output) messages = parse_wgrib2_inventory(output)
@ -152,7 +152,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
try do try do
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"] 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} -> {output, 0} ->
messages = parse_wgrib2_inventory(output) messages = parse_wgrib2_inventory(output)
@ -274,7 +274,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
"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} -> {output, 0} ->
# Parse message inventory from stdout to know which vars were extracted # Parse message inventory from stdout to know which vars were extracted
messages = parse_wgrib2_inventory(output) messages = parse_wgrib2_inventory(output)
@ -421,7 +421,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
try do try do
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"] 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} -> {output, 0} ->
messages = parse_wgrib2_inventory(output) messages = parse_wgrib2_inventory(output)
@ -593,7 +593,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
args = [grib_path, "-s", "-match", match_pattern] ++ lon_args 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} -> {output, 0} ->
{:ok, parse_lon_output(output, points)} {:ok, parse_lon_output(output, points)}

View file

@ -20,6 +20,8 @@ defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
import Ecto.Query import Ecto.Query
alias Microwaveprop.Repo alias Microwaveprop.Repo
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.NarrClient
require Logger require Logger
@ -110,11 +112,6 @@ defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
""" """
@spec enqueue_for_contacts([map()]) :: {:ok, non_neg_integer()} @spec enqueue_for_contacts([map()]) :: {:ok, non_neg_integer()}
def enqueue_for_contacts(contacts) do def enqueue_for_contacts(contacts) do
alias Microwaveprop.Radio
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.NarrClient
groups = groups =
contacts contacts
|> Enum.flat_map(&contact_points/1) |> 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 # HRRR archive starts mid-2014; contacts older than that belong to
# NARR, so they're dropped here. # NARR, so they're dropped here.
defp contact_points(contact) do defp contact_points(contact) do
alias Microwaveprop.Radio
alias Microwaveprop.Weather
alias Microwaveprop.Weather.HrrrClient
alias Microwaveprop.Weather.NarrClient
cond do cond do
is_nil(contact.pos1) -> 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) inputs = Enum.map(record_paths, fn {_key, path} -> path end)
args = ["-merge"] ++ inputs ++ [merged_path] 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, 0} -> :ok
{output, code} -> {:error, "cdo -merge exited #{code}: #{output}"} {output, code} -> {:error, "cdo -merge exited #{code}: #{output}"}
end end
@ -311,7 +311,7 @@ defmodule Microwaveprop.Weather.NarrClient do
grb_path 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} -> {stdout, 0} ->
parse_cdo_outputtab(stdout) 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: "climatology", min_samples: 3})
Microwaveprop.Workers.AdminTaskWorker.new(%{task: "native_derive", limit: 10000}) 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 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 Runs backfill enrichment enqueue as an Oban job so the backfill dashboard
returns immediately instead of blocking on the enqueue loop. 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 import Ecto.Query

View file

@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.CanadianSoundingFetchWorker do
and upserts it into `soundings`. and upserts it into `soundings`.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :weather, queue: :weather,
max_attempts: 3, max_attempts: 3,
unique: [period: 3600, states: :incomplete] 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. enqueue paths collapse to a single job per contact.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :radar, queue: :radar,
max_attempts: 3, max_attempts: 3,
unique: [ unique: [

View file

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

View file

@ -7,7 +7,7 @@ defmodule Microwaveprop.Workers.ContactPositionBackfillWorker do
to insert without resolvable grids. This worker exists for rows that arrive to insert without resolvable grids. This worker exists for rows that arrive
via direct DB writes (bulk imports, manual fixes) which bypass that path. 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 import Ecto.Query

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
@moduledoc false @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.Propagation.Grid
alias Microwaveprop.Radio alias Microwaveprop.Radio

View file

@ -11,7 +11,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
0.125° grid still carries ~200k cells. 0.125° grid still carries ~200k cells.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :gefs, queue: :gefs,
max_attempts: 5, max_attempts: 5,
unique: [period: 3600, states: :incomplete] 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. at a lower priority so the hourly grid chain never waits behind it.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :propagation, queue: :propagation,
priority: 5, priority: 5,
max_attempts: 3, max_attempts: 3,

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorker do
idempotent on (station_code, valid_time). idempotent on (station_code, valid_time).
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :ionosphere, queue: :ionosphere,
max_attempts: 3, max_attempts: 3,
unique: [period: 300, states: :incomplete] 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. backfill cron collapse to a single job per contact.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :mechanism, queue: :mechanism,
max_attempts: 3, max_attempts: 3,
unique: [ 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 See `docs/plans/2026-04-15-merra2-historical-backfill.md` for the full
architecture. architecture.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :narr, queue: :narr,
max_attempts: 3, max_attempts: 3,
unique: [ unique: [

View file

@ -7,7 +7,7 @@ defmodule Microwaveprop.Workers.NexradWorker do
sweeps that enqueue duplicate timestamps collapse automatically. sweeps that enqueue duplicate timestamps collapse automatically.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :nexrad, queue: :nexrad,
max_attempts: 3, max_attempts: 3,
unique: [ 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 an extra empty partition is one zero-row table, while the
cost of a missing partition is silently failed inserts. cost of a missing partition is silently failed inserts.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :admin, queue: :admin,
max_attempts: 3, max_attempts: 3,
unique: [period: 3600, states: :incomplete] unique: [period: 3600, states: :incomplete]

View file

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

View file

@ -9,7 +9,7 @@ defmodule Microwaveprop.Workers.PropagationPruneWorker do
regardless of the compute worker's state. regardless of the compute worker's state.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :propagation, queue: :propagation,
# Lower priority than PropagationGridWorker so the hourly chain # Lower priority than PropagationGridWorker so the hourly chain
# never waits behind a pending prune on the shared :propagation # 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 lag for the current hour); the rolling-window cron remains the
safety net. safety net.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :backfill_enqueue, queue: :backfill_enqueue,
max_attempts: 3, max_attempts: 3,
unique: [ unique: [

View file

@ -14,7 +14,7 @@ defmodule Microwaveprop.Workers.PskrRecalibrationWorker do
produces a fresh run record; previous runs stay queryable for produces a fresh run record; previous runs stay queryable for
trend comparison. trend comparison.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :backfill_enqueue, queue: :backfill_enqueue,
max_attempts: 3, max_attempts: 3,
unique: [ 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 # 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 # contacts each cycle would otherwise enqueue a fresh 1-job-per-contact
# frame fetch. 9k+ wasteful duplicates observed in prod before this guard. # frame fetch. 9k+ wasteful duplicates observed in prod before this guard.
use Oban.Worker, use Oban.Pro.Worker,
queue: :radar, queue: :radar,
max_attempts: 3, max_attempts: 3,
unique: [fields: [:args], keys: [:frame_ts], states: :incomplete, period: :infinity] unique: [fields: [:args], keys: [:frame_ts], states: :incomplete, period: :infinity]

View file

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

View file

@ -13,7 +13,7 @@ defmodule Microwaveprop.Workers.RoverMissionReconcileWorker do
tuples that aren't already represented. tuples that aren't already represented.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :terrain, queue: :terrain,
priority: 3, priority: 3,
max_attempts: 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 `:erlang.term_to_binary/1` + Base64. PathLive decodes this when
handed `?rover_path_id=UUID` and renders without recomputing. handed `?rover_path_id=UUID` and renders without recomputing.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :rover_path, queue: :rover_path,
max_attempts: 5, max_attempts: 5,
unique: [ unique: [

View file

@ -1,6 +1,6 @@
defmodule Microwaveprop.Workers.SolarIndexWorker do defmodule Microwaveprop.Workers.SolarIndexWorker do
@moduledoc false @moduledoc false
use Oban.Worker, use Oban.Pro.Worker,
queue: :solar, queue: :solar,
max_attempts: 3, max_attempts: 3,
unique: [period: 300, states: :incomplete] 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. fetch is independent: a failure in one product doesn't block the others.
""" """
use Oban.Worker, use Oban.Pro.Worker,
queue: :space_weather, queue: :space_weather,
max_attempts: 3, max_attempts: 3,
unique: [period: 120, states: :incomplete] 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 with `elevation_m = nil`. The worker also no-ops if the station has
been deleted between enqueue and execution. 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.Repo
alias Microwaveprop.Rover.FixedStation alias Microwaveprop.Rover.FixedStation

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,6 +11,7 @@ defmodule MicrowavepropWeb.Components.SkewTChart do
attr :profile, :list, required: true attr :profile, :list, required: true
@spec skew_t_chart(map()) :: Phoenix.LiveView.Rendered.t()
def skew_t_chart(assigns) do def skew_t_chart(assigns) do
chart = SkewT.build(assigns.profile) chart = SkewT.build(assigns.profile)
usable_points = if chart, do: length(chart.t_points), else: 0 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 "expires_at": "ISO8601" (optional)}`. Returns the plaintext token
and the persisted record. and the persisted record.
""" """
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do def create(conn, params) do
with {:ok, email} <- fetch_string(params, "email"), with {:ok, email} <- fetch_string(params, "email"),
{:ok, password} <- fetch_string(params, "password"), {:ok, password} <- fetch_string(params, "password"),

View file

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

View file

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

View file

@ -3,7 +3,10 @@ defmodule MicrowavepropWeb.Api.V1.BeaconMonitorJSON do
alias Microwaveprop.BeaconMonitors.BeaconMonitor alias Microwaveprop.BeaconMonitors.BeaconMonitor
@spec index(map()) :: map()
def index(%{monitors: monitors}), do: %{data: Enum.map(monitors, &data/1)} def index(%{monitors: monitors}), do: %{data: Enum.map(monitors, &data/1)}
@spec show(map()) :: map()
def show(%{monitor: monitor}), do: %{data: data(monitor)} def show(%{monitor: monitor}), do: %{data: data(monitor)}
defp data(%BeaconMonitor{} = m) do 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 snr_peak_db snr_avg_db signal_active_fraction
propmonitor_version) propmonitor_version)
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
def create(conn, params) do def create(conn, params) do
monitor = conn.assigns.current_monitor monitor = conn.assigns.current_monitor
attrs = Map.take(params, @wire_fields) attrs = Map.take(params, @wire_fields)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -70,7 +70,6 @@ defmodule MicrowavepropWeb.MapLive do
resolve_view(params, session, recovered_band, recovered_time) resolve_view(params, session, recovered_band, recovered_time)
visitor = visitor_location(session) visitor = visitor_location(session)
bounds = bounds_around(center)
# Score data is fetched by the client over HTTP (see /scores/cells # Score data is fetched by the client over HTTP (see /scores/cells
# and propagation_map_hook.ts). The LiveView only owns timeline, # and propagation_map_hook.ts). The LiveView only owns timeline,
@ -86,7 +85,6 @@ defmodule MicrowavepropWeb.MapLive do
valid_times: valid_times, valid_times: valid_times,
selected_time: selected_time, selected_time: selected_time,
tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()), tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
bounds: bounds,
initial_center: center, initial_center: center,
initial_zoom: zoom, initial_zoom: zoom,
current_center: center, current_center: center,
@ -173,18 +171,6 @@ defmodule MicrowavepropWeb.MapLive do
DateTime.compare(t, earliest) != :lt and DateTime.compare(t, latest) != :gt DateTime.compare(t, earliest) != :lt and DateTime.compare(t, latest) != :gt
end 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 @impl true
def handle_params(_params, _url, socket) do def handle_params(_params, _url, socket) do
# URL params are applied in mount/3; subsequent push_patch calls # 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, fixed_stations: fixed_stations,
persisted?: persisted?, persisted?: persisted?,
home: home, home: home,
valid_times: valid_times,
current_valid_time: current_valid_time, current_valid_time: current_valid_time,
top_candidates: [], top_candidates: [],
selected_candidate: nil, selected_candidate: nil,

View file

@ -17,10 +17,12 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
@valid_status_filters ~w(good bad) @valid_status_filters ~w(good bad)
@spec table_options() :: map()
def table_options do def table_options do
%{sorting: %{default_sort: [inserted_at: :desc]}} %{sorting: %{default_sort: [inserted_at: :desc]}}
end end
@spec fields() :: keyword()
def fields do def fields do
[ [
id: %{label: "ID", hidden: true}, id: %{label: "ID", hidden: true},
@ -33,11 +35,13 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
] ]
end end
@spec filters() :: list()
def filters, do: [] def filters, do: []
@doc false @doc false
# Called by LiveTable to get the base queryable. Filters by status when # 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. # 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 def visible_query_provider(status_filter) do
base = from(l in Location, as: :resource) base = from(l in Location, as: :resource)

View file

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

View file

@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
alias Microwaveprop.Accounts alias Microwaveprop.Accounts
@spec table_options() :: map()
def table_options do def table_options do
%{exports: %{formats: [:csv]}} %{exports: %{formats: [:csv]}}
end end
@ -14,6 +15,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
{:ok, assign(socket, :page_title, "Users")} {:ok, assign(socket, :page_title, "Users")}
end end
@spec fields() :: keyword()
def fields do def fields do
[ [
id: %{label: "ID", hidden: true}, id: %{label: "ID", hidden: true},
@ -25,8 +27,10 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
] ]
end end
@spec filters() :: list()
def filters, do: [] def filters, do: []
@spec actions() :: keyword()
def actions do def actions do
[ [
edit: fn %{record: user} -> 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 # Public for direct unit testing: anchoring an integration test on a
# truncated `utc_now()` would only assert the new behavior past minute # truncated `utc_now()` would only assert the new behavior past minute
# 30 of the wall clock. # 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(valid_times, now \\ DateTime.utc_now())
def pick_initial_valid_time([], _now), do: nil 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 # Find unused: defined but never called
unused = unused =
all_defs all_defs
|> Enum.reject(fn {mod, name, arity} -> |> Enum.reject(&function_referenced?(&1, all_calls, skip_external))
# 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.sort() |> Enum.sort()
if unused == [] do if unused == [] do
@ -356,13 +340,7 @@ defmodule Mix.Tasks.Unused do
unused unused
|> Enum.group_by(&elem(&1, 0), &{elem(&1, 1), elem(&1, 2)}) |> Enum.group_by(&elem(&1, 0), &{elem(&1, 1), elem(&1, 2)})
|> Enum.sort() |> Enum.sort()
|> Enum.each(fn {mod, functions} -> |> Enum.each(&print_module_functions/1)
Mix.shell().info(" #{mod}")
Enum.each(functions, fn {name, arity} ->
Mix.shell().info(" #{name}/#{arity}")
end)
end)
end end
# Verbose: show all functions checked # Verbose: show all functions checked
@ -372,12 +350,35 @@ defmodule Mix.Tasks.Unused do
all_defs all_defs
|> Enum.sort() |> Enum.sort()
|> Enum.each(fn {mod, name, arity} -> |> 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}]") Mix.shell().info(" #{mod}.#{name}/#{arity} [#{status}]")
end) end)
end 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()} @spec analyze_beam(String.t()) :: {MapSet.t(), MapSet.t()}
defp analyze_beam(beam_file) do defp analyze_beam(beam_file) do
module = beam_module(beam_file) 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. # doesn't have live_table so it doesn't need the override.
{:oban_web, "~> 2.12", path: "vendor/oban_web", override: true}, {:oban_web, "~> 2.12", path: "vendor/oban_web", override: true},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:jump_credo_checks, "~> 0.4", only: [:dev, :test], runtime: false},
{:nx, "~> 0.9", only: [:dev, :test]}, {:nx, "~> 0.9", only: [:dev, :test]},
{:axon, "~> 0.7", only: [:dev, :test]}, {:axon, "~> 0.7", only: [:dev, :test]},
{:exla, "~> 0.9", only: [:dev, :test]}, {:exla, "~> 0.9", only: [:dev, :test]},
@ -223,7 +224,7 @@ defmodule Microwaveprop.MixProject do
true -> true ->
patch_path = Path.absname(Path.join(@patches_dir, filename)) 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 if exit_code == 0 do
Mix.shell().info("deps.patch: applied #{filename}") Mix.shell().info("deps.patch: applied #{filename}")

View file

@ -29,6 +29,7 @@
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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) changeset = User.registration_changeset(%User{}, @valid_attrs)
assert changeset.valid? assert changeset.valid?
hashed = Ecto.Changeset.get_change(changeset, :hashed_password) hashed = Ecto.Changeset.get_change(changeset, :hashed_password)
assert is_binary(hashed) assert byte_size(hashed) > 0
assert String.starts_with?(hashed, "$2b$") or String.starts_with?(hashed, "$2a$") assert String.starts_with?(hashed, "$2b$")
refute Ecto.Changeset.get_change(changeset, :password) refute Ecto.Changeset.get_change(changeset, :password)
end end
@ -126,7 +126,7 @@ defmodule Microwaveprop.Accounts.UserTest do
|> User.registration_changeset(@valid_attrs) |> User.registration_changeset(@valid_attrs)
|> Repo.insert() |> Repo.insert()
assert is_binary(user.hashed_password) assert byte_size(user.hashed_password) > 0
assert user.password == nil assert user.password == nil
end end
@ -360,7 +360,7 @@ defmodule Microwaveprop.Accounts.UserTest do
assert changeset.valid? assert changeset.valid?
hashed = Ecto.Changeset.get_change(changeset, :hashed_password) hashed = Ecto.Changeset.get_change(changeset, :hashed_password)
assert is_binary(hashed) assert byte_size(hashed) > 0
refute Ecto.Changeset.get_change(changeset, :password) refute Ecto.Changeset.get_change(changeset, :password)
end end
@ -465,7 +465,7 @@ defmodule Microwaveprop.Accounts.UserTest do
changeset = User.change_home_qth(%User{}, %{"home_lat" => 33.10, "home_lon" => -96.625}) changeset = User.change_home_qth(%User{}, %{"home_lat" => 33.10, "home_lon" => -96.625})
assert changeset.valid? assert changeset.valid?
grid = Ecto.Changeset.get_change(changeset, :home_grid) grid = Ecto.Changeset.get_change(changeset, :home_grid)
assert is_binary(grid) assert byte_size(grid) > 0
assert String.length(grid) == 10 assert String.length(grid) == 10
assert String.starts_with?(grid, "EM13qc") assert String.starts_with?(grid, "EM13qc")
end end

View file

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

View file

@ -151,7 +151,7 @@ defmodule Microwaveprop.AccountsTest do
assert user.email == attrs.email assert user.email == attrs.email
assert user.callsign == String.upcase(attrs.callsign) assert user.callsign == String.upcase(attrs.callsign)
assert user.name == attrs.name 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.password)
assert is_nil(user.confirmed_at) assert is_nil(user.confirmed_at)
refute user.is_admin refute user.is_admin

View file

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

View file

@ -182,7 +182,7 @@ defmodule Microwaveprop.Backtest.FeaturesTest do
describe "all_features/0" do describe "all_features/0" do
test "returns a name-keyed map of 3-arity feature closures" do test "returns a name-keyed map of 3-arity feature closures" do
features = Features.all_features() 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, "naive_gradient")
assert Map.has_key?(features, "td_depression") assert Map.has_key?(features, "td_depression")
assert Map.has_key?(features, "time_of_day") 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 # Every value is a 3-arity closure that returns a float or nil
# without raising. # 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) assert is_function(fun, 3)
result = fun.(@point_lat, @point_lon, @valid_time) result = fun.(@point_lat, @point_lon, @valid_time)
assert is_nil(result) or is_float(result) assert result == nil
end end
end end
end end

View file

@ -151,7 +151,7 @@ defmodule Microwaveprop.BacktestTest do
results = Backtest.consolidated_report(features, sample_size: 10, baseline_size: 5) results = Backtest.consolidated_report(features, sample_size: 10, baseline_size: 5)
assert is_list(results) assert length(results) > 0
assert length(results) == 3 assert length(results) == 3
one = Enum.find(results, &(&1.feature_name == "always_one")) 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.user_id == user.id
assert monitor.name == "Shack Pi" 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 # strong_rand_bytes(32) |> url_encode64(padding: false) is 43 chars
assert String.length(monitor.token) == 43 assert String.length(monitor.token) == 43
end end

View file

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

View file

@ -53,12 +53,12 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
result = RangeEstimate.estimate(beacon()) result = RangeEstimate.estimate(beacon())
assert result.band_mhz == 10_000 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_float(result.eirp_dbm)
assert is_list(result.cells) assert length(result.cells) > 0
assert result.cells != [] assert result.cells != []
assert result.grid_step == 0.125 assert result.grid_step == 0.125
assert is_list(result.tiers) assert length(result.tiers) > 0
end end
test "each cell carries lat/lon, distance, rx_dbm, tier label/color" do 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.lon)
assert is_float(cell.distance_km) assert is_float(cell.distance_km)
assert is_integer(cell.rx_dbm) assert is_integer(cell.rx_dbm)
assert is_binary(cell.label) assert byte_size(cell.label) > 0
assert is_binary(cell.color) assert byte_size(cell.color) > 0
# Detection floor enforced # Detection floor enforced
assert cell.rx_dbm >= -145 assert cell.rx_dbm >= -145
end end
@ -93,7 +93,7 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
test "uses a default score of 50 when no propagation data exists" do test "uses a default score of 50 when no propagation data exists" do
result = RangeEstimate.estimate(beacon()) 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.center_score == 50
assert result.valid_time == nil 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) assert Enum.all?(result.cells, fn c -> c.score == 50 end)
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()) result = RangeEstimate.estimate(beacon())
for cell <- result.cells do 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 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 # 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. # into the map's data attribute was locking up browsers in prod.
result = RangeEstimate.estimate(beacon(frequency_mhz: 432.38, power_mw: 1000.0)) result = RangeEstimate.estimate(beacon(frequency_mhz: 432.38, power_mw: 1000.0))
assert result.cells == [] assert result.cells == []

View file

@ -19,8 +19,8 @@ defmodule Microwaveprop.Buildings.BulkFetchTest do
describe "run/4" do describe "run/4" do
test "returns the result struct with zero counters when index is empty" 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 # Stub the dataset index fetch to return empty TSV -> no quadkeys
# in index no downloads attempted. # in index -> no downloads attempted.
Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts -> Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts ->
{:ok, %{status: 200, body: "Location\tQuadkey\tUrl\tSize\n"}} {:ok, %{status: 200, body: "Location\tQuadkey\tUrl\tSize\n"}}
end) end)
@ -42,7 +42,7 @@ defmodule Microwaveprop.Buildings.BulkFetchTest do
} }
assert is_integer(missing) assert is_integer(missing)
assert is_binary(cache_dir) assert byte_size(cache_dir) > 0
end end
end 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} bbox = %{"south" => 32.5, "north" => 33.0, "west" => -97.5, "east" => -96.5}
keys = Loader.ensure_loaded_for_bbox(bbox) keys = Loader.ensure_loaded_for_bbox(bbox)
assert is_list(keys) assert length(keys) > 0
assert keys != [] assert keys != []
assert Enum.all?(keys, &is_binary/1) assert Enum.all?(keys, &is_binary/1)
end end

View file

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

View file

@ -80,7 +80,7 @@ defmodule Microwaveprop.CallsignLocationTest do
assert result.callsign == "W1AW" assert result.callsign == "W1AW"
assert_in_delta result.latitude, 41.714775, 0.001 assert_in_delta result.latitude, 41.714775, 0.001
assert_in_delta result.longitude, -72.727260, 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 String.length(result.gridsquare) == 8
assert result.name == "Hiram Percy Maxim" assert result.name == "Hiram Percy Maxim"
assert result.address == "225 Main St, Newington, CT, 06111, United States" assert result.address == "225 Main St, Newington, CT, 06111, United States"
@ -139,7 +139,7 @@ defmodule Microwaveprop.CallsignLocationTest do
assert result.callsign == "W1AW" assert result.callsign == "W1AW"
assert_in_delta result.latitude, 33.229, 0.001 assert_in_delta result.latitude, 33.229, 0.001
assert_in_delta result.longitude, -96.458, 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 String.length(result.gridsquare) == 8
assert result.name == "Hiram Percy Maxim" assert result.name == "Hiram Percy Maxim"
assert result.address == "225 Main St, Newington, CT, 06111, United States" 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) 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 end
test "logs and skips when create_sample rejects the changeset" do 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) result = Commercial.link_degradation_at(@near_point, valid_time)
assert result assert %{degradation_db: degradation, n_links: 1} = result
assert_in_delta result.degradation_db, 10.0, 0.5 assert_in_delta degradation, 10.0, 0.5
assert result.n_links == 1
end end
test "returns 0 degradation when rx_power matches baseline" do 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) result = Commercial.link_degradation_at(@near_point, valid_time)
assert result assert %{degradation_db: degradation} = result
assert_in_delta result.degradation_db, 0.0, 0.5 assert_in_delta degradation, 0.0, 0.5
end end
test "ignores links with link_state != 1 (down link isn't tropo)" do 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) capture_log(fn -> execute_exception(meta) end)
assert_receive {:permanent, 101}, 200 assert_receive {:permanent, 101}
end end
test "invokes on_transient_failure/1 on transient failure when callback defined" do 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) capture_log(fn -> execute_exception(meta) end)
assert_receive {:transient, 202}, 200 assert_receive {:transient, 202}
end end
test "dispatches only the matching callback when both are defined" do 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) capture_log(fn -> execute_exception(meta_transient) end)
assert_receive {:transient, 303}, 200 assert_receive {:transient, 303}
refute_receive {:permanent, 303}, 50 refute_receive {:permanent, 303}, 50
meta_permanent = meta_permanent =
@ -212,7 +212,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
capture_log(fn -> execute_exception(meta_permanent) end) capture_log(fn -> execute_exception(meta_permanent) end)
assert_receive {:permanent, 404}, 200 assert_receive {:permanent, 404}
refute_receive {:transient, 404}, 50 refute_receive {:transient, 404}, 50
end end

View file

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

View file

@ -14,10 +14,9 @@ defmodule Microwaveprop.Propagation.CalibrationTest do
test "uncorrelated input lands near zero" do test "uncorrelated input lands near zero" do
xs = Enum.to_list(1..100) 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) ys = List.duplicate(42, 100)
result = Calibration.spearman(xs, ys) assert Calibration.spearman(xs, ys) == 0.0
assert result == nil or abs(result) < 0.05
end end
test "returns nil for fewer than 3 pairs" do test "returns nil for fewer than 3 pairs" do
@ -30,112 +29,5 @@ defmodule Microwaveprop.Propagation.CalibrationTest do
test "zero for identical lists" do test "zero for identical lists" do
assert Calibration.rmse([1, 2, 3], [1, 2, 3]) == 0.0 assert Calibration.rmse([1, 2, 3], [1, 2, 3]) == 0.0
end 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
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 length(rows) == 28
assert rows |> Enum.map(& &1.forecast_hour) |> Enum.sort() == expected_fhs assert rows |> Enum.map(& &1.forecast_hour) |> Enum.sort() == expected_fhs
assert Enum.all?(rows, &(&1.status == "queued")) assert Enum.all?(rows, &(&1.status == "queued"))

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