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:
parent
e879f291f7
commit
fd976b0cd5
166 changed files with 773 additions and 1887 deletions
51
.credo.exs
51
.credo.exs
|
|
@ -31,7 +31,7 @@
|
|||
"apps/*/test/",
|
||||
"apps/*/web/"
|
||||
],
|
||||
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
|
||||
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/", ~r"/vendor/"]
|
||||
},
|
||||
#
|
||||
# Load and configure plugins here:
|
||||
|
|
@ -41,7 +41,28 @@
|
|||
# If you create your own checks, you must specify the source files for
|
||||
# them here, so they can be loaded by Credo before running the analysis.
|
||||
#
|
||||
requires: [],
|
||||
requires: [
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/assert_element_selector_can_never_fail.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/assert_receive_timeout.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/avoid_function_level_else.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/avoid_logger_configure_in_test.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/avoid_socket_assigns_in_test.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/conditional_assertion.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/doctest_iex_examples.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/forbidden_function.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/live_view_form_can_be_rehydrated.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/prefer_change_over_up_down_migrations.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/prefer_text_columns.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/safe_binary_to_term.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/test_has_no_assertions.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/too_many_assertions.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/top_level_alias_import_require.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/undeclared_external_resource.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/unused_live_view_assign.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/use_oban_pro_worker.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/vacuous_test.ex",
|
||||
"deps/jump_credo_checks/lib/jump/credo_checks/weak_assertion.ex"
|
||||
],
|
||||
#
|
||||
# If you want to enforce a style guide and need a more traditional linting
|
||||
# experience, you can change `strict` to `true` below:
|
||||
|
|
@ -163,7 +184,31 @@
|
|||
{Credo.Check.Warning.WrongTestFilename, []},
|
||||
{Credo.Check.Readability.Specs, []},
|
||||
{Credo.Check.Warning.LeakyEnvironment, []},
|
||||
{Credo.Check.Warning.UnsafeToAtom, []}
|
||||
{Credo.Check.Warning.UnsafeToAtom, []},
|
||||
|
||||
#
|
||||
## Jump Credo Checks
|
||||
#
|
||||
{Jump.CredoChecks.AssertElementSelectorCanNeverFail, []},
|
||||
{Jump.CredoChecks.AssertReceiveTimeout, []},
|
||||
{Jump.CredoChecks.AvoidFunctionLevelElse, []},
|
||||
{Jump.CredoChecks.AvoidLoggerConfigureInTest, []},
|
||||
{Jump.CredoChecks.AvoidSocketAssignsInTest, []},
|
||||
{Jump.CredoChecks.ConditionalAssertion, []},
|
||||
{Jump.CredoChecks.DoctestIExExamples, []},
|
||||
{Jump.CredoChecks.ForbiddenFunction, []},
|
||||
{Jump.CredoChecks.LiveViewFormCanBeRehydrated, []},
|
||||
{Jump.CredoChecks.UndeclaredExternalResource, []},
|
||||
{Jump.CredoChecks.PreferChangeOverUpDownMigrations, [start_after: "0"]},
|
||||
{Jump.CredoChecks.PreferTextColumns, [start_after: "0"]},
|
||||
{Jump.CredoChecks.SafeBinaryToTerm, []},
|
||||
{Jump.CredoChecks.TestHasNoAssertions, []},
|
||||
{Jump.CredoChecks.TooManyAssertions, []},
|
||||
{Jump.CredoChecks.TopLevelAliasImportRequire, []},
|
||||
{Jump.CredoChecks.UnusedLiveViewAssign, []},
|
||||
{Jump.CredoChecks.UseObanProWorker, []},
|
||||
{Jump.CredoChecks.VacuousTest, []},
|
||||
{Jump.CredoChecks.WeakAssertion, []}
|
||||
],
|
||||
disabled: [
|
||||
#
|
||||
|
|
|
|||
10
format_commands.sh
Normal file
10
format_commands.sh
Normal 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
|
||||
|
|
@ -5,6 +5,8 @@ defmodule Microwaveprop.Application do
|
|||
|
||||
use Application
|
||||
|
||||
alias MicrowavepropWeb.Api.RateLimiter
|
||||
|
||||
require Logger
|
||||
|
||||
@build_timestamp DateTime.utc_now()
|
||||
|
|
@ -13,7 +15,7 @@ defmodule Microwaveprop.Application do
|
|||
def start(_type, _args) do
|
||||
# Eagerly create the API rate limiter's ETS table so the plug never
|
||||
# races to create it on the request path.
|
||||
:ok = MicrowavepropWeb.Api.RateLimiter.init_table()
|
||||
:ok = RateLimiter.init_table()
|
||||
|
||||
topologies = Application.get_env(:libcluster, :topologies, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ defmodule Microwaveprop.Buildings.Parser do
|
|||
height is known. Lazy; suitable for tiles with millions of rows.
|
||||
"""
|
||||
@dialyzer {:no_return, parse_tile: 1}
|
||||
@spec parse_tile(Path.t()) :: Stream.t([building_record()])
|
||||
def parse_tile(path) do
|
||||
path
|
||||
|> File.stream!([:read, :compressed])
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ defmodule Microwaveprop.Canopy.BulkFetch do
|
|||
out_path
|
||||
]
|
||||
|
||||
case System.cmd("gdal_translate", args, stderr_to_stdout: true) do
|
||||
case System.cmd("gdal_translate", args, env: %{}, stderr_to_stdout: true) do
|
||||
{_, 0} ->
|
||||
# Discard ENVI sidecar metadata.
|
||||
Enum.each([".hdr", ".aux.xml"], fn ext -> _ = File.rm(out_path <> ext) end)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Microwaveprop.Commercial.PollWorker do
|
||||
@moduledoc false
|
||||
use Oban.Worker, queue: :commercial, max_attempts: 1
|
||||
use Oban.Pro.Worker, queue: :commercial, max_attempts: 1
|
||||
|
||||
alias Microwaveprop.Commercial
|
||||
alias Microwaveprop.Commercial.SnmpClient
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ defmodule Microwaveprop.Commercial.SnmpClient do
|
|||
end
|
||||
|
||||
defp default_run_cmd(cmd, args) do
|
||||
System.cmd(cmd, args, stderr_to_stdout: true)
|
||||
System.cmd(cmd, args, env: %{}, stderr_to_stdout: true)
|
||||
rescue
|
||||
ErlangError -> {"command not found: #{cmd}", 127}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ defmodule Microwaveprop.Propagation.MechanismClassifier do
|
|||
"""
|
||||
|
||||
alias Microwaveprop.Propagation.MoonEphemeris
|
||||
alias Microwaveprop.Propagation.SporadicE
|
||||
|
||||
# ADIF PROP_MODE values we recognize. Anything else falls through to
|
||||
# physics-based classification.
|
||||
|
|
@ -244,7 +245,7 @@ defmodule Microwaveprop.Propagation.MechanismClassifier do
|
|||
defp try_sporadic_e(%{foes_mhz: nil}), do: :no_match
|
||||
|
||||
defp try_sporadic_e(%{foes_mhz: foes, band_mhz: band, distance_km: dist}) do
|
||||
muf_mhz = Microwaveprop.Propagation.SporadicE.single_hop_muf(foes, dist)
|
||||
muf_mhz = SporadicE.single_hop_muf(foes, dist)
|
||||
|
||||
if muf_mhz >= band do
|
||||
confidence = if muf_mhz >= 1.5 * band, do: :high, else: :medium
|
||||
|
|
|
|||
|
|
@ -76,9 +76,11 @@ defmodule Microwaveprop.Propagation.PathCompute do
|
|||
@total_stages length(@stages)
|
||||
|
||||
@doc "Total number of progress stages emitted by `compute/5`."
|
||||
@spec total_stages() :: integer()
|
||||
def total_stages, do: @total_stages
|
||||
|
||||
@doc "Ordered stage labels emitted by `compute/5` (1-indexed)."
|
||||
@spec stage_labels() :: list(String.t())
|
||||
def stage_labels, do: @stages
|
||||
|
||||
@doc """
|
||||
|
|
@ -368,20 +370,7 @@ defmodule Microwaveprop.Propagation.PathCompute do
|
|||
defp build_scoring([], _src, _dst, _now, _band_config, _native_duct), do: {nil, nil}
|
||||
|
||||
defp build_scoring(profiles, src, dst, now, band_config, native_duct) do
|
||||
{temps, dewpoints, pressures, gradients, bl_depths, pwats} =
|
||||
Enum.reduce(profiles, {[], [], [], [], [], []}, fn p, {ts, ds, ps, gs, bs, ws} ->
|
||||
{
|
||||
if(p.surface_temp_c == nil, do: ts, else: [p.surface_temp_c | ts]),
|
||||
if(p.surface_dewpoint_c == nil, do: ds, else: [p.surface_dewpoint_c | ds]),
|
||||
if(p.surface_pressure_mb == nil, do: ps, else: [p.surface_pressure_mb | ps]),
|
||||
if(p.min_refractivity_gradient == nil,
|
||||
do: gs,
|
||||
else: [p.min_refractivity_gradient | gs]
|
||||
),
|
||||
if(p.hpbl_m == nil, do: bs, else: [p.hpbl_m | bs]),
|
||||
if(p.pwat_mm == nil, do: ws, else: [p.pwat_mm | ws])
|
||||
}
|
||||
end)
|
||||
{temps, dewpoints, pressures, gradients, bl_depths, pwats} = collect_profile_fields(profiles)
|
||||
|
||||
if temps == [] or dewpoints == [] do
|
||||
{nil, nil}
|
||||
|
|
@ -389,35 +378,57 @@ defmodule Microwaveprop.Propagation.PathCompute do
|
|||
avg_temp_c = Enum.sum(temps) / length(temps)
|
||||
avg_dewpoint_c = Enum.sum(dewpoints) / length(dewpoints)
|
||||
|
||||
conditions = %{
|
||||
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
|
||||
temp_f: Scorer.c_to_f(avg_temp_c),
|
||||
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
|
||||
temp_c: avg_temp_c,
|
||||
dewpoint_c: avg_dewpoint_c,
|
||||
wind_speed_kts: nil,
|
||||
sky_cover_pct: nil,
|
||||
utc_hour: now.hour,
|
||||
utc_minute: now.minute,
|
||||
month: now.month,
|
||||
latitude: (src.lat + dst.lat) / 2,
|
||||
longitude: (src.lon + dst.lon) / 2,
|
||||
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
|
||||
prev_pressure_mb: nil,
|
||||
rain_rate_mmhr: 0.0,
|
||||
min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
|
||||
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
|
||||
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
|
||||
best_duct_band_ghz: native_duct[:best_duct_band_ghz],
|
||||
bulk_richardson: native_duct[:bulk_richardson]
|
||||
}
|
||||
conditions =
|
||||
build_conditions(avg_temp_c, avg_dewpoint_c, src, dst, now, {pressures, gradients, bl_depths, pwats}, native_duct)
|
||||
|
||||
scoring = Scorer.composite_score(conditions, band_config)
|
||||
{conditions, scoring}
|
||||
end
|
||||
end
|
||||
|
||||
defp collect_profile_fields(profiles) do
|
||||
Enum.reduce(profiles, {[], [], [], [], [], []}, fn p, {ts, ds, ps, gs, bs, ws} ->
|
||||
{
|
||||
if(p.surface_temp_c == nil, do: ts, else: [p.surface_temp_c | ts]),
|
||||
if(p.surface_dewpoint_c == nil, do: ds, else: [p.surface_dewpoint_c | ds]),
|
||||
if(p.surface_pressure_mb == nil, do: ps, else: [p.surface_pressure_mb | ps]),
|
||||
if(p.min_refractivity_gradient == nil,
|
||||
do: gs,
|
||||
else: [p.min_refractivity_gradient | gs]
|
||||
),
|
||||
if(p.hpbl_m == nil, do: bs, else: [p.hpbl_m | bs]),
|
||||
if(p.pwat_mm == nil, do: ws, else: [p.pwat_mm | ws])
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_conditions(avg_temp_c, avg_dewpoint_c, src, dst, now, {pressures, gradients, bl_depths, pwats}, native_duct) do
|
||||
%{
|
||||
abs_humidity: Scorer.absolute_humidity(avg_temp_c, avg_dewpoint_c),
|
||||
temp_f: Scorer.c_to_f(avg_temp_c),
|
||||
dewpoint_f: Scorer.c_to_f(avg_dewpoint_c),
|
||||
temp_c: avg_temp_c,
|
||||
dewpoint_c: avg_dewpoint_c,
|
||||
wind_speed_kts: nil,
|
||||
sky_cover_pct: nil,
|
||||
utc_hour: now.hour,
|
||||
utc_minute: now.minute,
|
||||
month: now.month,
|
||||
latitude: (src.lat + dst.lat) / 2,
|
||||
longitude: (src.lon + dst.lon) / 2,
|
||||
pressure_mb: if(pressures != [], do: Enum.min(pressures)),
|
||||
prev_pressure_mb: nil,
|
||||
rain_rate_mmhr: 0.0,
|
||||
min_refractivity_gradient: if(gradients != [], do: Enum.min(gradients)),
|
||||
bl_depth_m: if(bl_depths != [], do: Enum.sum(bl_depths) / length(bl_depths)),
|
||||
pwat_mm: if(pwats != [], do: Enum.sum(pwats) / length(pwats)),
|
||||
best_duct_band_ghz: native_duct[:best_duct_band_ghz],
|
||||
bulk_richardson: native_duct[:bulk_richardson]
|
||||
}
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec compute_loss_budget(float(), float(), map(), map() | nil, map() | nil) :: map()
|
||||
def compute_loss_budget(dist_km, freq_ghz, band_config, terrain_result, conditions) do
|
||||
freq_mhz = freq_ghz * 1000
|
||||
fspl = 20 * :math.log10(max(dist_km, 0.001)) + 20 * :math.log10(freq_mhz) + 32.44
|
||||
|
|
@ -462,6 +473,7 @@ defmodule Microwaveprop.Propagation.PathCompute do
|
|||
end
|
||||
|
||||
@doc false
|
||||
@spec compute_power_budget(station_params(), map()) :: map()
|
||||
def compute_power_budget(station_params, loss_budget) do
|
||||
tx_power_dbm = station_params.tx_power_dbm
|
||||
eirp_dbm = tx_power_dbm + station_params.src_gain_dbi
|
||||
|
|
|
|||
|
|
@ -466,6 +466,7 @@ defmodule Microwaveprop.Radio do
|
|||
defp deg_to_rad(deg), do: deg * :math.pi() / 180
|
||||
|
||||
@dialyzer {:no_match, get_contact!: 1}
|
||||
@spec get_contact!(term()) :: Contact.t()
|
||||
def get_contact!(id) do
|
||||
Contact
|
||||
|> Repo.get!(id)
|
||||
|
|
|
|||
|
|
@ -545,24 +545,7 @@ defmodule Microwaveprop.Weather do
|
|||
{values_sql, params} =
|
||||
updates
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce({"", []}, fn u, {sql, params} ->
|
||||
base = Enum.count(params)
|
||||
frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})"
|
||||
sep = if sql == "", do: "", else: ", "
|
||||
|
||||
params =
|
||||
params ++
|
||||
[
|
||||
Ecto.UUID.dump!(u.id),
|
||||
u.surface_refractivity,
|
||||
u.min_refractivity_gradient,
|
||||
u.ducting_detected,
|
||||
u.duct_characteristics || [],
|
||||
now
|
||||
]
|
||||
|
||||
{sql <> sep <> frag, params}
|
||||
end)
|
||||
|> Enum.reduce({"", []}, &build_values_row(&1, &2, now))
|
||||
|
||||
Repo.query!(
|
||||
"UPDATE hrrr_profiles AS h " <>
|
||||
|
|
@ -582,6 +565,25 @@ defmodule Microwaveprop.Weather do
|
|||
length(updates)
|
||||
end
|
||||
|
||||
defp build_values_row({u, idx}, {sql, params}, now) do
|
||||
base = Enum.count(params)
|
||||
frag = "($#{base + 1}::uuid, $#{base + 2}, $#{base + 3}, $#{base + 4}, $#{base + 5}, $#{base + 6})"
|
||||
sep = if sql == "", do: "", else: ", "
|
||||
|
||||
params =
|
||||
params ++
|
||||
[
|
||||
Ecto.UUID.dump!(u.id),
|
||||
u.surface_refractivity,
|
||||
u.min_refractivity_gradient,
|
||||
u.ducting_detected,
|
||||
u.duct_characteristics || [],
|
||||
now
|
||||
]
|
||||
|
||||
{sql <> sep <> frag, params}
|
||||
end
|
||||
|
||||
@doc """
|
||||
True if the station already has at least one surface observation
|
||||
anywhere within the given UTC date. Used by the `asos_day` worker
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
|
|||
try do
|
||||
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"]
|
||||
|
||||
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
|
||||
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
|
||||
{output, 0} ->
|
||||
messages = parse_wgrib2_inventory(output)
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
|
|||
try do
|
||||
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"]
|
||||
|
||||
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
|
||||
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
|
||||
{output, 0} ->
|
||||
messages = parse_wgrib2_inventory(output)
|
||||
|
||||
|
|
@ -274,7 +274,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
|
|||
"bin"
|
||||
]
|
||||
|
||||
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
|
||||
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
|
||||
{output, 0} ->
|
||||
# Parse message inventory from stdout to know which vars were extracted
|
||||
messages = parse_wgrib2_inventory(output)
|
||||
|
|
@ -421,7 +421,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
|
|||
try do
|
||||
args = [grib_path, "-match", match_pattern, "-lola", lon_spec, lat_spec, tmp_bin, "bin"]
|
||||
|
||||
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
|
||||
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
|
||||
{output, 0} ->
|
||||
messages = parse_wgrib2_inventory(output)
|
||||
|
||||
|
|
@ -593,7 +593,7 @@ defmodule Microwaveprop.Weather.Grib2.Wgrib2 do
|
|||
|
||||
args = [grib_path, "-s", "-match", match_pattern] ++ lon_args
|
||||
|
||||
case System.cmd(wgrib2_path(), args, stderr_to_stdout: true) do
|
||||
case System.cmd(wgrib2_path(), args, env: %{}, stderr_to_stdout: true) do
|
||||
{output, 0} ->
|
||||
{:ok, parse_lon_output(output, points)}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
|
|||
import Ecto.Query
|
||||
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.Weather.HrrrClient
|
||||
alias Microwaveprop.Weather.NarrClient
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -110,11 +112,6 @@ defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
|
|||
"""
|
||||
@spec enqueue_for_contacts([map()]) :: {:ok, non_neg_integer()}
|
||||
def enqueue_for_contacts(contacts) do
|
||||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.HrrrClient
|
||||
alias Microwaveprop.Weather.NarrClient
|
||||
|
||||
groups =
|
||||
contacts
|
||||
|> Enum.flat_map(&contact_points/1)
|
||||
|
|
@ -129,11 +126,6 @@ defmodule Microwaveprop.Weather.HrrrPointEnqueuer do
|
|||
# HRRR archive starts mid-2014; contacts older than that belong to
|
||||
# NARR, so they're dropped here.
|
||||
defp contact_points(contact) do
|
||||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Weather
|
||||
alias Microwaveprop.Weather.HrrrClient
|
||||
alias Microwaveprop.Weather.NarrClient
|
||||
|
||||
cond do
|
||||
is_nil(contact.pos1) ->
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -298,7 +298,7 @@ defmodule Microwaveprop.Weather.NarrClient do
|
|||
inputs = Enum.map(record_paths, fn {_key, path} -> path end)
|
||||
args = ["-merge"] ++ inputs ++ [merged_path]
|
||||
|
||||
case System.cmd("cdo", args, stderr_to_stdout: true) do
|
||||
case System.cmd("cdo", args, env: %{}, stderr_to_stdout: true) do
|
||||
{_output, 0} -> :ok
|
||||
{output, code} -> {:error, "cdo -merge exited #{code}: #{output}"}
|
||||
end
|
||||
|
|
@ -311,7 +311,7 @@ defmodule Microwaveprop.Weather.NarrClient do
|
|||
grb_path
|
||||
]
|
||||
|
||||
case System.cmd("cdo", args, stderr_to_stdout: true) do
|
||||
case System.cmd("cdo", args, env: %{}, stderr_to_stdout: true) do
|
||||
{stdout, 0} ->
|
||||
parse_cdo_outputtab(stdout)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.AdminTaskWorker do
|
|||
Microwaveprop.Workers.AdminTaskWorker.new(%{task: "climatology", min_samples: 3})
|
||||
Microwaveprop.Workers.AdminTaskWorker.new(%{task: "native_derive", limit: 10000})
|
||||
"""
|
||||
use Oban.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
|
||||
use Oban.Pro.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ defmodule Microwaveprop.Workers.BackfillEnqueueWorker do
|
|||
Runs backfill enrichment enqueue as an Oban job so the backfill dashboard
|
||||
returns immediately instead of blocking on the enqueue loop.
|
||||
"""
|
||||
use Oban.Worker, queue: :backfill_enqueue, max_attempts: 1
|
||||
use Oban.Pro.Worker, queue: :backfill_enqueue, max_attempts: 1
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.CanadianSoundingFetchWorker do
|
|||
and upserts it into `soundings`.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :weather,
|
||||
max_attempts: 3,
|
||||
unique: [period: 3600, states: :incomplete]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.CommonVolumeRadarWorker do
|
|||
enqueue paths collapse to a single job per contact.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :radar,
|
||||
max_attempts: 3,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule Microwaveprop.Workers.ContactImportWorker do
|
|||
that sees `status = 'pending'` on its first increment flips it to
|
||||
`"running"` and sets `started_at`.
|
||||
"""
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :contact_import,
|
||||
max_attempts: 3,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ defmodule Microwaveprop.Workers.ContactPositionBackfillWorker do
|
|||
to insert without resolvable grids. This worker exists for rows that arrive
|
||||
via direct DB writes (bulk imports, manual fixes) which bypass that path.
|
||||
"""
|
||||
use Oban.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
|
||||
use Oban.Pro.Worker, queue: :admin, max_attempts: 1, unique: [period: 60]
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Microwaveprop.Workers.ContactWeatherEnqueueWorker do
|
||||
@moduledoc false
|
||||
use Oban.Worker, queue: :enqueue, max_attempts: 3
|
||||
use Oban.Pro.Worker, queue: :enqueue, max_attempts: 3
|
||||
|
||||
alias Microwaveprop.Propagation.Grid
|
||||
alias Microwaveprop.Radio
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ defmodule Microwaveprop.Workers.GefsFetchWorker do
|
|||
0.125° grid still carries ~200k cells.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :gefs,
|
||||
max_attempts: 5,
|
||||
unique: [period: 3600, states: :incomplete]
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defmodule Microwaveprop.Workers.GridCachePruneWorker do
|
|||
at a lower priority so the hourly grid chain never waits behind it.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :propagation,
|
||||
priority: 5,
|
||||
max_attempts: 3,
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ defmodule Microwaveprop.Workers.HrdpsGridWorker do
|
|||
enqueuer call back to `GridTaskEnqueuer.seed_with_analysis/2`.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :hrdps,
|
||||
priority: 0,
|
||||
max_attempts: 5,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorker do
|
|||
in a single pass.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :hrrr,
|
||||
max_attempts: 3,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Microwaveprop.Workers.IemreFetchWorker do
|
||||
@moduledoc false
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :iemre,
|
||||
max_attempts: 20,
|
||||
unique: [period: 300, fields: [:args], states: :scheduled]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorker do
|
|||
idempotent on (station_code, valid_time).
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :ionosphere,
|
||||
max_attempts: 3,
|
||||
unique: [period: 300, states: :incomplete]
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ defmodule Microwaveprop.Workers.MechanismClassifyWorker do
|
|||
backfill cron collapse to a single job per contact.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :mechanism,
|
||||
max_attempts: 3,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule Microwaveprop.Workers.NarrFetchWorker do
|
|||
See `docs/plans/2026-04-15-merra2-historical-backfill.md` for the full
|
||||
architecture.
|
||||
"""
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :narr,
|
||||
max_attempts: 3,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ defmodule Microwaveprop.Workers.NexradWorker do
|
|||
sweeps that enqueue duplicate timestamps collapse automatically.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :nexrad,
|
||||
max_attempts: 3,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule Microwaveprop.Workers.PartitionMaintenanceWorker do
|
|||
cost of an extra empty partition is one zero-row table, while the
|
||||
cost of a missing partition is silently failed inserts.
|
||||
"""
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :admin,
|
||||
max_attempts: 3,
|
||||
unique: [period: 3600, states: :incomplete]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule Microwaveprop.Workers.PropagationGridWorker do
|
|||
chain step, through the PropagationNotifyListener path.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :propagation,
|
||||
priority: 0,
|
||||
max_attempts: 5,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defmodule Microwaveprop.Workers.PropagationPruneWorker do
|
|||
regardless of the compute worker's state.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :propagation,
|
||||
# Lower priority than PropagationGridWorker so the hourly chain
|
||||
# never waits behind a pending prune on the shared :propagation
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ defmodule Microwaveprop.Workers.PskrCalibrationWorker do
|
|||
lag for the current hour); the rolling-window cron remains the
|
||||
safety net.
|
||||
"""
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :backfill_enqueue,
|
||||
max_attempts: 3,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ defmodule Microwaveprop.Workers.PskrRecalibrationWorker do
|
|||
produces a fresh run record; previous runs stay queryable for
|
||||
trend comparison.
|
||||
"""
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :backfill_enqueue,
|
||||
max_attempts: 3,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ defmodule Microwaveprop.Workers.RadarFrameWorker do
|
|||
# unique on frame_ts: backfill sweeps that re-select the same still-:queued
|
||||
# contacts each cycle would otherwise enqueue a fresh 1-job-per-contact
|
||||
# frame fetch. 9k+ wasteful duplicates observed in prod before this guard.
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :radar,
|
||||
max_attempts: 3,
|
||||
unique: [fields: [:args], keys: [:frame_ts], states: :incomplete, period: :infinity]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ defmodule Microwaveprop.Workers.RoverMissionBackfillWorker do
|
|||
without operator action.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :terrain,
|
||||
priority: 5,
|
||||
max_attempts: 3,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ defmodule Microwaveprop.Workers.RoverMissionReconcileWorker do
|
|||
tuples that aren't already represented.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :terrain,
|
||||
priority: 3,
|
||||
max_attempts: 3,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ defmodule Microwaveprop.Workers.RoverPathProfileWorker do
|
|||
`:erlang.term_to_binary/1` + Base64. PathLive decodes this when
|
||||
handed `?rover_path_id=UUID` and renders without recomputing.
|
||||
"""
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :rover_path,
|
||||
max_attempts: 5,
|
||||
unique: [
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Microwaveprop.Workers.SolarIndexWorker do
|
||||
@moduledoc false
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :solar,
|
||||
max_attempts: 3,
|
||||
unique: [period: 300, states: :incomplete]
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ defmodule Microwaveprop.Workers.SpaceWeatherFetchWorker do
|
|||
fetch is independent: a failure in one product doesn't block the others.
|
||||
"""
|
||||
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :space_weather,
|
||||
max_attempts: 3,
|
||||
unique: [period: 120, states: :incomplete]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ defmodule Microwaveprop.Workers.StationElevationWorker do
|
|||
with `elevation_m = nil`. The worker also no-ops if the station has
|
||||
been deleted between enqueue and execution.
|
||||
"""
|
||||
use Oban.Worker, queue: :terrain, max_attempts: 3
|
||||
use Oban.Pro.Worker, queue: :terrain, max_attempts: 3
|
||||
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.Rover.FixedStation
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Microwaveprop.Workers.TerrainProfileWorker do
|
||||
@moduledoc false
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :terrain,
|
||||
max_attempts: 20,
|
||||
unique: [period: 300, states: :incomplete]
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ defmodule Microwaveprop.Workers.UserHomeQthLookupWorker do
|
|||
end)
|
||||
"""
|
||||
|
||||
use Oban.Worker, queue: :weather, max_attempts: 3
|
||||
use Oban.Pro.Worker, queue: :weather, max_attempts: 3
|
||||
|
||||
alias Microwaveprop.Accounts
|
||||
alias Microwaveprop.Accounts.User
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
defmodule Microwaveprop.Workers.WeatherFetchWorker do
|
||||
@moduledoc false
|
||||
use Oban.Worker,
|
||||
use Oban.Pro.Worker,
|
||||
queue: :weather,
|
||||
max_attempts: 20,
|
||||
unique: [period: :infinity, states: :incomplete]
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ defmodule MicrowavepropWeb.Api.FallbackController do
|
|||
|
||||
alias MicrowavepropWeb.Api.ErrorJSON
|
||||
|
||||
@spec call(Plug.Conn.t(), term()) :: Plug.Conn.t()
|
||||
def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
|
||||
ErrorJSON.send_changeset(conn, changeset)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
alias Phoenix.HTML.Form
|
||||
alias Phoenix.HTML.FormField
|
||||
alias Phoenix.LiveView.JS
|
||||
alias Phoenix.LiveView.Rendered
|
||||
|
||||
@doc """
|
||||
Renders flash notices.
|
||||
|
|
@ -48,6 +49,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
||||
@spec flash(map()) :: Rendered.t()
|
||||
def flash(assigns) do
|
||||
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
|
||||
|
||||
|
|
@ -94,6 +96,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
attr :variant, :string, values: ~w(primary)
|
||||
slot :inner_block, required: true
|
||||
|
||||
@spec button(map()) :: Rendered.t()
|
||||
def button(%{rest: rest} = assigns) do
|
||||
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
|
||||
|
||||
|
|
@ -180,6 +183,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
attr :rest, :global, include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
|
||||
multiple pattern placeholder readonly required rows size step)
|
||||
|
||||
@spec input(map()) :: Rendered.t()
|
||||
def input(%{field: %FormField{} = field} = assigns) do
|
||||
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
|
||||
|
||||
|
|
@ -317,6 +321,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
slot :subtitle
|
||||
slot :actions
|
||||
|
||||
@spec header(map()) :: Rendered.t()
|
||||
def header(assigns) do
|
||||
~H"""
|
||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
|
||||
|
|
@ -339,8 +344,8 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
## Examples
|
||||
|
||||
<.table id="users" rows={@users}>
|
||||
<:col :let={user} label="id">{user.id}</:col>
|
||||
<:col :let={user} label="username">{user.username}</:col>
|
||||
<:col :let={row} label="id">{row.id}</:col>
|
||||
<:col :let={row} label="username">{row.username}</:col>
|
||||
</.table>
|
||||
"""
|
||||
attr :id, :string, required: true
|
||||
|
|
@ -363,6 +368,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
|
||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||
|
||||
@spec table(map()) :: Rendered.t()
|
||||
def table(assigns) do
|
||||
assigns =
|
||||
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
|
||||
|
|
@ -440,6 +446,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
attr :title, :string, required: true
|
||||
end
|
||||
|
||||
@spec list(map()) :: Rendered.t()
|
||||
def list(assigns) do
|
||||
~H"""
|
||||
<ul class="list">
|
||||
|
|
@ -456,14 +463,14 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
@doc """
|
||||
Renders a [Heroicon](https://heroicons.com).
|
||||
|
||||
Heroicons come in three styles – outline, solid, and mini.
|
||||
Heroicons come in three styles -- outline, solid, and mini.
|
||||
By default, the outline style is used, but solid and mini may
|
||||
be applied by using the `-solid` and `-mini` suffix.
|
||||
|
||||
You can customize the size and colors of the icons by setting
|
||||
width, height, and background color classes.
|
||||
|
||||
Icons are extracted from the `deps/heroicons` directory and bundled within
|
||||
Icons are extracted from the `deps/heroicon` directory and bundled within
|
||||
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
|
||||
|
||||
## Examples
|
||||
|
|
@ -474,6 +481,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
attr :name, :string, required: true
|
||||
attr :class, :any, default: "size-4"
|
||||
|
||||
@spec icon(map()) :: Rendered.t()
|
||||
def icon(%{name: "hero-" <> _} = assigns) do
|
||||
~H"""
|
||||
<span class={[@name, @class]} />
|
||||
|
|
@ -482,6 +490,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
|
||||
## JS Commands
|
||||
|
||||
@spec show(JS.t(), String.t()) :: JS.t()
|
||||
def show(js \\ %JS{}, selector) do
|
||||
JS.show(js,
|
||||
to: selector,
|
||||
|
|
@ -492,6 +501,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
)
|
||||
end
|
||||
|
||||
@spec hide(JS.t(), String.t()) :: JS.t()
|
||||
def hide(js \\ %JS{}, selector) do
|
||||
JS.hide(js,
|
||||
to: selector,
|
||||
|
|
@ -506,6 +516,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
Interpolates an Ecto changeset error message. No i18n; plural messages
|
||||
are left as-is (Ecto emits the appropriate form based on `:count`).
|
||||
"""
|
||||
@spec translate_error({String.t(), keyword()}) :: String.t()
|
||||
def translate_error({msg, opts}) do
|
||||
Enum.reduce(opts, msg, fn {key, value}, acc ->
|
||||
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
|
||||
|
|
@ -515,6 +526,7 @@ defmodule MicrowavepropWeb.CoreComponents do
|
|||
@doc """
|
||||
Translates the errors for a field from a keyword list of errors.
|
||||
"""
|
||||
@spec translate_errors(Ecto.Changeset.t(), atom()) :: String.t()
|
||||
def translate_errors(errors, field) when is_list(errors) do
|
||||
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule MicrowavepropWeb.Components.SkewTChart do
|
|||
|
||||
attr :profile, :list, required: true
|
||||
|
||||
@spec skew_t_chart(map()) :: Phoenix.LiveView.Rendered.t()
|
||||
def skew_t_chart(assigns) do
|
||||
chart = SkewT.build(assigns.profile)
|
||||
usable_points = if chart, do: length(chart.t_points), else: 0
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ defmodule MicrowavepropWeb.Api.V1.AuthController do
|
|||
"expires_at": "ISO8601" (optional)}`. Returns the plaintext token
|
||||
and the persisted record.
|
||||
"""
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, params) do
|
||||
with {:ok, email} <- fetch_string(params, "email"),
|
||||
{:ok, password} <- fetch_string(params, "password"),
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ defmodule MicrowavepropWeb.Api.V1.BeaconController do
|
|||
plug :accepts, ["json"]
|
||||
action_fallback MicrowavepropWeb.Api.FallbackController
|
||||
|
||||
@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def index(conn, _params) do
|
||||
beacons = Beacons.list_beacons()
|
||||
json(conn, BeaconJSON.index(%{beacons: beacons}))
|
||||
end
|
||||
|
||||
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def show(conn, %{"id" => id}) do
|
||||
viewer = conn.assigns[:current_api_user]
|
||||
|
||||
|
|
@ -24,6 +26,7 @@ defmodule MicrowavepropWeb.Api.V1.BeaconController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, params) do
|
||||
user = conn.assigns.current_api_user
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ defmodule MicrowavepropWeb.Api.V1.BeaconJSON do
|
|||
|
||||
alias Microwaveprop.Beacons.Beacon
|
||||
|
||||
@spec index(map()) :: map()
|
||||
def index(%{beacons: beacons}), do: %{data: Enum.map(beacons, &data/1)}
|
||||
|
||||
@spec show(map()) :: map()
|
||||
def show(%{beacon: beacon}), do: %{data: data(beacon)}
|
||||
|
||||
defp data(%Beacon{} = b) do
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ defmodule MicrowavepropWeb.Api.V1.BeaconMonitorJSON do
|
|||
|
||||
alias Microwaveprop.BeaconMonitors.BeaconMonitor
|
||||
|
||||
@spec index(map()) :: map()
|
||||
def index(%{monitors: monitors}), do: %{data: Enum.map(monitors, &data/1)}
|
||||
|
||||
@spec show(map()) :: map()
|
||||
def show(%{monitor: monitor}), do: %{data: data(monitor)}
|
||||
|
||||
defp data(%BeaconMonitor{} = m) do
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ defmodule MicrowavepropWeb.Api.V1.BeaconMonitorMeasurementController do
|
|||
snr_peak_db snr_avg_db signal_active_fraction
|
||||
propmonitor_version)
|
||||
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, params) do
|
||||
monitor = conn.assigns.current_monitor
|
||||
attrs = Map.take(params, @wire_fields)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactController do
|
|||
|
||||
@max_per_page 200
|
||||
|
||||
@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def index(conn, params) do
|
||||
page = params |> Map.get("page", "1") |> parse_int(1)
|
||||
per_page = params |> Map.get("per_page", "50") |> parse_int(50) |> min(@max_per_page) |> max(1)
|
||||
|
|
@ -42,6 +43,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactController do
|
|||
)
|
||||
end
|
||||
|
||||
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def show(conn, %{"id" => id}) do
|
||||
viewer = conn.assigns[:current_api_user]
|
||||
|
||||
|
|
@ -61,6 +63,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, params) do
|
||||
user = conn.assigns.current_api_user
|
||||
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ defmodule MicrowavepropWeb.Api.V1.ContactJSON do
|
|||
alias Microwaveprop.Accounts.User
|
||||
alias Microwaveprop.Radio.Contact
|
||||
|
||||
@spec index(map()) :: map()
|
||||
def index(%{contacts: contacts} = assigns) do
|
||||
viewer = Map.get(assigns, :viewer)
|
||||
%{data: Enum.map(contacts, &data(&1, viewer))}
|
||||
end
|
||||
|
||||
@spec index_paginated(map()) :: map()
|
||||
def index_paginated(%{contacts: contacts} = assigns) do
|
||||
viewer = Map.get(assigns, :viewer)
|
||||
|
||||
|
|
@ -23,6 +25,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactJSON do
|
|||
}
|
||||
end
|
||||
|
||||
@spec show(map()) :: map()
|
||||
def show(%{contact: contact} = assigns) do
|
||||
viewer = Map.get(assigns, :viewer)
|
||||
%{data: data(contact, viewer)}
|
||||
|
|
|
|||
|
|
@ -22,11 +22,13 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
|
|||
|
||||
## Profile -----------------------------------------------------------
|
||||
|
||||
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def show(conn, _params) do
|
||||
user = conn.assigns.current_api_user
|
||||
json(conn, UserJSON.me(user))
|
||||
end
|
||||
|
||||
@spec update(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def update(conn, params) do
|
||||
user = conn.assigns.current_api_user
|
||||
attrs = Map.take(params, ["home_grid", "home_lat", "home_lon", "home_elevation_m"])
|
||||
|
|
@ -39,6 +41,7 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
|
|||
|
||||
## My contacts -------------------------------------------------------
|
||||
|
||||
@spec contacts(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def contacts(conn, _params) do
|
||||
user = conn.assigns.current_api_user
|
||||
contacts = Radio.list_contacts_for_user(user, user)
|
||||
|
|
@ -47,6 +50,7 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
|
|||
|
||||
## My beacons --------------------------------------------------------
|
||||
|
||||
@spec beacons(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def beacons(conn, _params) do
|
||||
user = conn.assigns.current_api_user
|
||||
beacons = Beacons.list_beacons_for_user(user, user)
|
||||
|
|
@ -55,12 +59,14 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
|
|||
|
||||
## API tokens --------------------------------------------------------
|
||||
|
||||
@spec list_tokens(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def list_tokens(conn, _params) do
|
||||
user = conn.assigns.current_api_user
|
||||
tokens = Accounts.list_api_tokens(user)
|
||||
json(conn, TokenJSON.index(%{tokens: tokens}))
|
||||
end
|
||||
|
||||
@spec revoke_token(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def revoke_token(conn, %{"id" => id}) do
|
||||
user = conn.assigns.current_api_user
|
||||
|
||||
|
|
@ -71,12 +77,14 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
|
|||
|
||||
## Beacon monitors ---------------------------------------------------
|
||||
|
||||
@spec list_monitors(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def list_monitors(conn, _params) do
|
||||
user = conn.assigns.current_api_user
|
||||
monitors = BeaconMonitors.list_monitors_for_user(user)
|
||||
json(conn, BeaconMonitorJSON.index(%{monitors: monitors}))
|
||||
end
|
||||
|
||||
@spec create_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create_monitor(conn, params) do
|
||||
user = conn.assigns.current_api_user
|
||||
attrs = Map.take(params, ["name"])
|
||||
|
|
@ -92,6 +100,7 @@ defmodule MicrowavepropWeb.Api.V1.MeController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec delete_monitor(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def delete_monitor(conn, %{"id" => id}) do
|
||||
user = conn.assigns.current_api_user
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ defmodule MicrowavepropWeb.Api.V1.ProfileController do
|
|||
|
||||
plug :accepts, ["json"]
|
||||
|
||||
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def show(conn, %{"callsign" => callsign}) do
|
||||
case Accounts.get_user_by_callsign(callsign) do
|
||||
nil ->
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ defmodule MicrowavepropWeb.Api.V1.ScoreController do
|
|||
|
||||
plug :accepts, ["json"]
|
||||
|
||||
@spec bands(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def bands(conn, _params) do
|
||||
list =
|
||||
Enum.map(BandConfig.all_freqs(), fn mhz ->
|
||||
|
|
@ -27,6 +28,7 @@ defmodule MicrowavepropWeb.Api.V1.ScoreController do
|
|||
json(conn, %{data: list})
|
||||
end
|
||||
|
||||
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def show(conn, params) do
|
||||
with {:ok, band} <- parse_band(params["band"]),
|
||||
{:ok, lat} <- parse_float(params["lat"], "lat"),
|
||||
|
|
@ -44,6 +46,7 @@ defmodule MicrowavepropWeb.Api.V1.ScoreController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec forecast(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def forecast(conn, params) do
|
||||
with {:ok, band} <- parse_band(params["band"]),
|
||||
{:ok, lat} <- parse_float(params["lat"], "lat"),
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@ defmodule MicrowavepropWeb.Api.V1.TokenJSON do
|
|||
alias Microwaveprop.Accounts.UserApiToken
|
||||
|
||||
@doc "Token list view (no plaintext — never recoverable after creation)."
|
||||
@spec index(map()) :: map()
|
||||
def index(%{tokens: tokens}) do
|
||||
%{data: Enum.map(tokens, &data/1)}
|
||||
end
|
||||
|
||||
@doc "Single token without plaintext."
|
||||
@spec show(map()) :: map()
|
||||
def show(%{token: token}), do: %{data: data(token)}
|
||||
|
||||
@doc "Single token including the one-time plaintext value at the top level."
|
||||
@spec show_with_plaintext(UserApiToken.t(), String.t()) :: map()
|
||||
def show_with_plaintext(%UserApiToken{} = token, plaintext) do
|
||||
%{data: data(token), token: plaintext}
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ defmodule MicrowavepropWeb.Api.V1.UserJSON do
|
|||
alias Microwaveprop.Accounts.User
|
||||
|
||||
@doc "Public profile (no email)."
|
||||
@spec public(User.t()) :: map()
|
||||
def public(%User{} = user) do
|
||||
%{
|
||||
data: %{
|
||||
|
|
@ -18,6 +19,7 @@ defmodule MicrowavepropWeb.Api.V1.UserJSON do
|
|||
end
|
||||
|
||||
@doc "Authenticated /me view (includes email + admin flag)."
|
||||
@spec me(User.t()) :: map()
|
||||
def me(%User{} = user) do
|
||||
%{
|
||||
data: %{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ defmodule MicrowavepropWeb.ApiTokenController do
|
|||
|
||||
alias Microwaveprop.Accounts
|
||||
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, %{"api_token" => params}) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ defmodule MicrowavepropWeb.ApiTokenController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def delete(conn, %{"id" => id}) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ defmodule MicrowavepropWeb.BeaconMonitorController do
|
|||
|
||||
alias Microwaveprop.BeaconMonitors
|
||||
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, %{"beacon_monitor" => params}) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ defmodule MicrowavepropWeb.BeaconMonitorController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def delete(conn, %{"id" => id}) do
|
||||
user = conn.assigns.current_scope.user
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ defmodule MicrowavepropWeb.ContactMapController do
|
|||
@cache_ttl_ms 10 * 60 * 1_000
|
||||
|
||||
@doc false
|
||||
@spec cache_key() :: {module(), :gzipped_payload}
|
||||
def cache_key, do: @cache_key
|
||||
|
||||
@spec show(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
|
|
|
|||
|
|
@ -22,12 +22,14 @@ defmodule MicrowavepropWeb.HealthController do
|
|||
|
||||
alias Ecto.Adapters.SQL
|
||||
|
||||
@spec live(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def live(conn, _params) do
|
||||
conn
|
||||
|> put_resp_content_type("text/plain")
|
||||
|> send_resp(200, "ok")
|
||||
end
|
||||
|
||||
@spec check(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def check(conn, _params) do
|
||||
_ = SQL.query!(Microwaveprop.Repo, "SELECT 1")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
defmodule MicrowavepropWeb.PageController do
|
||||
use MicrowavepropWeb, :controller
|
||||
|
||||
@spec home(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def home(conn, _params) do
|
||||
redirect(conn, to: ~p"/map")
|
||||
end
|
||||
|
||||
@spec redirect_contacts(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def redirect_contacts(conn, _params) do
|
||||
redirect(conn, to: ~p"/contacts")
|
||||
end
|
||||
|
||||
@spec redirect_contact(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def redirect_contact(conn, %{"id" => id}) do
|
||||
redirect(conn, to: ~p"/contacts/#{id}")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ defmodule MicrowavepropWeb.UserRegistrationController do
|
|||
alias Microwaveprop.Accounts
|
||||
alias Microwaveprop.Accounts.User
|
||||
|
||||
@spec new(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def new(conn, _params) do
|
||||
changeset = Accounts.change_user_registration(%User{})
|
||||
render(conn, :new, changeset: changeset)
|
||||
end
|
||||
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
case Accounts.register_user(user_params) do
|
||||
{:ok, user} ->
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ defmodule MicrowavepropWeb.UserResetPasswordController do
|
|||
|
||||
plug :get_user_by_reset_password_token when action in [:edit, :update]
|
||||
|
||||
@spec new(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def new(conn, _params) do
|
||||
render(conn, :new)
|
||||
end
|
||||
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, %{"user" => %{"email" => email}}) do
|
||||
_ =
|
||||
if user = Accounts.get_user_by_email(email) do
|
||||
|
|
@ -30,6 +32,7 @@ defmodule MicrowavepropWeb.UserResetPasswordController do
|
|||
|> redirect(to: ~p"/users/log-in")
|
||||
end
|
||||
|
||||
@spec edit(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def edit(conn, _params) do
|
||||
render(conn, :edit,
|
||||
changeset: Accounts.change_user_password(conn.assigns.user),
|
||||
|
|
@ -37,6 +40,7 @@ defmodule MicrowavepropWeb.UserResetPasswordController do
|
|||
)
|
||||
end
|
||||
|
||||
@spec update(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def update(conn, %{"user" => user_params}) do
|
||||
case Accounts.reset_user_password(conn.assigns.user, user_params) do
|
||||
{:ok, _} ->
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.UserSessionController do
|
|||
alias Microwaveprop.Accounts.User
|
||||
alias MicrowavepropWeb.UserAuth
|
||||
|
||||
@spec new(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def new(conn, _params) do
|
||||
email = get_in(conn.assigns, [:current_scope, Access.key(:user), Access.key(:email)])
|
||||
form = Phoenix.Component.to_form(%{"email" => email}, as: "user")
|
||||
|
|
@ -12,6 +13,7 @@ defmodule MicrowavepropWeb.UserSessionController do
|
|||
render(conn, :new, form: form)
|
||||
end
|
||||
|
||||
@spec create(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create(conn, %{"user" => %{"email" => email, "password" => password} = user_params}) do
|
||||
case Accounts.get_user_by_email_and_password(email, password) do
|
||||
%User{confirmed_at: nil} ->
|
||||
|
|
@ -39,6 +41,7 @@ defmodule MicrowavepropWeb.UserSessionController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec confirm(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def confirm(conn, %{"token" => token}) do
|
||||
case Accounts.confirm_user_by_token(token) do
|
||||
{:ok, {user, _expired_tokens}} ->
|
||||
|
|
@ -53,6 +56,7 @@ defmodule MicrowavepropWeb.UserSessionController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def delete(conn, _params) do
|
||||
conn
|
||||
|> put_flash(:info, "Logged out successfully.")
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ defmodule MicrowavepropWeb.UserSettingsController do
|
|||
plug :assign_beacon_monitors
|
||||
plug :assign_api_tokens
|
||||
|
||||
@spec edit(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def edit(conn, _params) do
|
||||
render(conn, :edit)
|
||||
end
|
||||
|
||||
@spec update(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def update(conn, %{"action" => "update_email"} = params) do
|
||||
%{"user" => user_params} = params
|
||||
user = conn.assigns.current_scope.user
|
||||
|
|
@ -73,6 +75,7 @@ defmodule MicrowavepropWeb.UserSettingsController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec confirm_email(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def confirm_email(conn, %{"token" => token}) do
|
||||
case Accounts.update_user_email(conn.assigns.current_scope.user, token) do
|
||||
{:ok, _user} ->
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
|
|||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Radio.EditNotifier
|
||||
|
||||
@spec table_options() :: map()
|
||||
def table_options do
|
||||
%{exports: %{formats: [:csv]}}
|
||||
end
|
||||
|
||||
@spec fields() :: keyword()
|
||||
def fields do
|
||||
[
|
||||
id: %{label: "ID", hidden: true},
|
||||
|
|
@ -20,8 +22,10 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
|
|||
]
|
||||
end
|
||||
|
||||
@spec filters() :: list()
|
||||
def filters, do: []
|
||||
|
||||
@spec actions() :: keyword()
|
||||
def actions do
|
||||
[
|
||||
review: fn %{record: edit} ->
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
alias Microwaveprop.Beacons
|
||||
alias Microwaveprop.Beacons.Beacon
|
||||
|
||||
@spec table_options() :: map()
|
||||
def table_options do
|
||||
%{exports: %{formats: [:csv]}}
|
||||
end
|
||||
|
||||
@spec fields() :: keyword()
|
||||
def fields do
|
||||
[
|
||||
id: %{label: "ID", hidden: true},
|
||||
|
|
@ -28,8 +30,10 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
]
|
||||
end
|
||||
|
||||
@spec filters() :: list()
|
||||
def filters, do: []
|
||||
|
||||
@spec actions() :: keyword()
|
||||
def actions do
|
||||
[
|
||||
show: fn %{record: beacon} ->
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
|||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
@spec table_options() :: map()
|
||||
def table_options do
|
||||
%{
|
||||
sorting: %{default_sort: [inserted_at: :desc]},
|
||||
|
|
@ -18,6 +19,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
|||
}
|
||||
end
|
||||
|
||||
@spec fields() :: keyword()
|
||||
def fields do
|
||||
[
|
||||
id: %{label: "ID", hidden: true},
|
||||
|
|
@ -35,8 +37,10 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
|||
]
|
||||
end
|
||||
|
||||
@spec filters() :: list()
|
||||
def filters, do: []
|
||||
|
||||
@spec actions() :: keyword()
|
||||
def actions do
|
||||
[
|
||||
view: fn %{record: contact} ->
|
||||
|
|
@ -141,6 +145,7 @@ defmodule MicrowavepropWeb.ContactLive.Index do
|
|||
@doc false
|
||||
# Called by LiveTable to get the base queryable. Must be a
|
||||
# named function because the helper persists MFA across renders.
|
||||
@spec visible_query_provider(scope_token :: term()) :: Ecto.Query.t()
|
||||
def visible_query_provider(scope_token) do
|
||||
scope_token
|
||||
|> scope_from_token()
|
||||
|
|
|
|||
|
|
@ -70,7 +70,6 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
resolve_view(params, session, recovered_band, recovered_time)
|
||||
|
||||
visitor = visitor_location(session)
|
||||
bounds = bounds_around(center)
|
||||
|
||||
# Score data is fetched by the client over HTTP (see /scores/cells
|
||||
# and propagation_map_hook.ts). The LiveView only owns timeline,
|
||||
|
|
@ -86,7 +85,6 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
valid_times: valid_times,
|
||||
selected_time: selected_time,
|
||||
tracking_now?: tracking_now?(selected_time, valid_times, DateTime.utc_now()),
|
||||
bounds: bounds,
|
||||
initial_center: center,
|
||||
initial_zoom: zoom,
|
||||
current_center: center,
|
||||
|
|
@ -173,18 +171,6 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
DateTime.compare(t, earliest) != :lt and DateTime.compare(t, latest) != :gt
|
||||
end
|
||||
|
||||
# Build a bounding box roughly matching the hardcoded default (~3.4° × 9°)
|
||||
# around a given center so the initial HRRR score query still returns a
|
||||
# useful tile set for the visible area.
|
||||
defp bounds_around(%{lat: lat, lon: lon}) do
|
||||
%{
|
||||
"south" => lat - 3.4,
|
||||
"north" => lat + 3.4,
|
||||
"west" => lon - 4.5,
|
||||
"east" => lon + 4.5
|
||||
}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
# URL params are applied in mount/3; subsequent push_patch calls
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ defmodule MicrowavepropWeb.RoverLive do
|
|||
fixed_stations: fixed_stations,
|
||||
persisted?: persisted?,
|
||||
home: home,
|
||||
valid_times: valid_times,
|
||||
current_valid_time: current_valid_time,
|
||||
top_candidates: [],
|
||||
selected_candidate: nil,
|
||||
|
|
|
|||
|
|
@ -17,10 +17,12 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
|
|||
|
||||
@valid_status_filters ~w(good bad)
|
||||
|
||||
@spec table_options() :: map()
|
||||
def table_options do
|
||||
%{sorting: %{default_sort: [inserted_at: :desc]}}
|
||||
end
|
||||
|
||||
@spec fields() :: keyword()
|
||||
def fields do
|
||||
[
|
||||
id: %{label: "ID", hidden: true},
|
||||
|
|
@ -33,11 +35,13 @@ defmodule MicrowavepropWeb.RoverLocationsLive do
|
|||
]
|
||||
end
|
||||
|
||||
@spec filters() :: list()
|
||||
def filters, do: []
|
||||
|
||||
@doc false
|
||||
# Called by LiveTable to get the base queryable. Filters by status when
|
||||
# the user picks one in the in-page select; nil means show all.
|
||||
@spec visible_query_provider(status_filter :: term()) :: Ecto.Query.t()
|
||||
def visible_query_provider(status_filter) do
|
||||
base = from(l in Location, as: :resource)
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
|
|||
alias Microwaveprop.RoverPlanning
|
||||
alias Microwaveprop.RoverPlanning.Mission
|
||||
|
||||
@spec table_options() :: map()
|
||||
def table_options do
|
||||
%{sorting: %{default_sort: [inserted_at: :desc]}}
|
||||
end
|
||||
|
||||
@spec fields() :: keyword()
|
||||
def fields do
|
||||
[
|
||||
id: %{label: "ID", hidden: true},
|
||||
|
|
@ -25,6 +27,7 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
|
|||
]
|
||||
end
|
||||
|
||||
@spec filters() :: list()
|
||||
def filters, do: []
|
||||
|
||||
@impl true
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
|
|||
|
||||
alias Microwaveprop.Accounts
|
||||
|
||||
@spec table_options() :: map()
|
||||
def table_options do
|
||||
%{exports: %{formats: [:csv]}}
|
||||
end
|
||||
|
|
@ -14,6 +15,7 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
|
|||
{:ok, assign(socket, :page_title, "Users")}
|
||||
end
|
||||
|
||||
@spec fields() :: keyword()
|
||||
def fields do
|
||||
[
|
||||
id: %{label: "ID", hidden: true},
|
||||
|
|
@ -25,8 +27,10 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
|
|||
]
|
||||
end
|
||||
|
||||
@spec filters() :: list()
|
||||
def filters, do: []
|
||||
|
||||
@spec actions() :: keyword()
|
||||
def actions do
|
||||
[
|
||||
edit: fn %{record: user} ->
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ defmodule MicrowavepropWeb.WeatherMapLive do
|
|||
# Public for direct unit testing: anchoring an integration test on a
|
||||
# truncated `utc_now()` would only assert the new behavior past minute
|
||||
# 30 of the wall clock.
|
||||
@spec pick_initial_valid_time([DateTime.t()], DateTime.t()) :: DateTime.t() | nil
|
||||
def pick_initial_valid_time(valid_times, now \\ DateTime.utc_now())
|
||||
def pick_initial_valid_time([], _now), do: nil
|
||||
|
||||
|
|
|
|||
|
|
@ -328,23 +328,7 @@ defmodule Mix.Tasks.Unused do
|
|||
# Find unused: defined but never called
|
||||
unused =
|
||||
all_defs
|
||||
|> Enum.reject(fn {mod, name, arity} ->
|
||||
# Skip ignored functions (OTP callbacks, compiler-generated, etc.)
|
||||
# Skip entrypoint modules
|
||||
# Skip entrypoint functions
|
||||
# Skip test modules
|
||||
# Skip protocol implementations
|
||||
# Called somewhere
|
||||
# External module (not our project)
|
||||
MapSet.member?(@ignored_functions, {name, arity}) or
|
||||
MapSet.member?(@entrypoint_modules, Atom.to_string(mod)) or
|
||||
MapSet.member?(@entrypoint_functions, {name, arity}) or
|
||||
String.ends_with?(Atom.to_string(mod), "Test") or
|
||||
String.ends_with?(Atom.to_string(mod), "Fixtures") or
|
||||
String.contains?(Atom.to_string(mod), ".Impl.") or
|
||||
MapSet.member?(all_calls, {mod, name, arity}) or
|
||||
(skip_external and not String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop"))
|
||||
end)
|
||||
|> Enum.reject(&function_referenced?(&1, all_calls, skip_external))
|
||||
|> Enum.sort()
|
||||
|
||||
if unused == [] do
|
||||
|
|
@ -356,13 +340,7 @@ defmodule Mix.Tasks.Unused do
|
|||
unused
|
||||
|> Enum.group_by(&elem(&1, 0), &{elem(&1, 1), elem(&1, 2)})
|
||||
|> Enum.sort()
|
||||
|> Enum.each(fn {mod, functions} ->
|
||||
Mix.shell().info(" #{mod}")
|
||||
|
||||
Enum.each(functions, fn {name, arity} ->
|
||||
Mix.shell().info(" #{name}/#{arity}")
|
||||
end)
|
||||
end)
|
||||
|> Enum.each(&print_module_functions/1)
|
||||
end
|
||||
|
||||
# Verbose: show all functions checked
|
||||
|
|
@ -372,12 +350,35 @@ defmodule Mix.Tasks.Unused do
|
|||
all_defs
|
||||
|> Enum.sort()
|
||||
|> Enum.each(fn {mod, name, arity} ->
|
||||
status = if MapSet.member?(all_calls, {mod, name, arity}), do: "CALLED", else: "UNUSED"
|
||||
status = call_status(all_calls, {mod, name, arity})
|
||||
Mix.shell().info(" #{mod}.#{name}/#{arity} [#{status}]")
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp call_status(all_calls, key), do: if(MapSet.member?(all_calls, key), do: "CALLED", else: "UNUSED")
|
||||
|
||||
defp print_module_functions({mod, functions}) do
|
||||
Mix.shell().info(" #{mod}")
|
||||
|
||||
Enum.each(functions, fn {name, arity} ->
|
||||
Mix.shell().info(" #{name}/#{arity}")
|
||||
end)
|
||||
end
|
||||
|
||||
defp function_referenced?({mod, name, arity}, all_calls, skip_external) do
|
||||
mod_str = Atom.to_string(mod)
|
||||
|
||||
MapSet.member?(@ignored_functions, {name, arity}) or
|
||||
MapSet.member?(@entrypoint_modules, mod_str) or
|
||||
MapSet.member?(@entrypoint_functions, {name, arity}) or
|
||||
String.ends_with?(mod_str, "Test") or
|
||||
String.ends_with?(mod_str, "Fixtures") or
|
||||
String.contains?(mod_str, ".Impl.") or
|
||||
MapSet.member?(all_calls, {mod, name, arity}) or
|
||||
(skip_external and not String.starts_with?(mod_str, "Elixir.Microwaveprop"))
|
||||
end
|
||||
|
||||
@spec analyze_beam(String.t()) :: {MapSet.t(), MapSet.t()}
|
||||
defp analyze_beam(beam_file) do
|
||||
module = beam_module(beam_file)
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -98,6 +98,7 @@ defmodule Microwaveprop.MixProject do
|
|||
# doesn't have live_table so it doesn't need the override.
|
||||
{:oban_web, "~> 2.12", path: "vendor/oban_web", override: true},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:jump_credo_checks, "~> 0.4", only: [:dev, :test], runtime: false},
|
||||
{:nx, "~> 0.9", only: [:dev, :test]},
|
||||
{:axon, "~> 0.7", only: [:dev, :test]},
|
||||
{:exla, "~> 0.9", only: [:dev, :test]},
|
||||
|
|
@ -223,7 +224,7 @@ defmodule Microwaveprop.MixProject do
|
|||
|
||||
true ->
|
||||
patch_path = Path.absname(Path.join(@patches_dir, filename))
|
||||
{output, exit_code} = System.cmd("patch", ["-p1", "-i", patch_path], cd: dep_dir)
|
||||
{output, exit_code} = System.cmd("patch", ["-p1", "-i", patch_path], env: %{}, cd: dep_dir)
|
||||
|
||||
if exit_code == 0 do
|
||||
Mix.shell().info("deps.patch: applied #{filename}")
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -29,6 +29,7 @@
|
|||
"idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"},
|
||||
"igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
|
||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||
"jump_credo_checks": {:hex, :jump_credo_checks, "0.4.0", "9dd5cbf6a9fca758c8a1664855434fc377393b58225e6ca8dc173763ee07487a", [:mix], [{:credo, "~> 1.7", [hex: :credo, repo: "hexpm", optional: false]}, {:igniter, ">= 0.0.0", [hex: :igniter, repo: "hexpm", optional: true]}], "hexpm", "89f51e654b5f4900dfcc8cfaae780d676bc9343ec072f6067594f0a5c2900a19"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
||||
"libcluster": {:hex, :libcluster, "3.5.0", "5ee4cfde4bdf32b2fef271e33ce3241e89509f4344f6c6a8d4069937484866ba", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebf6561fcedd765a4cd43b4b8c04b1c87f4177b5fb3cbdfe40a780499d72f743"},
|
||||
"live_stash": {:hex, :live_stash, "0.3.0", "3cd6f7fe1e6411e525118569c723f096ecb906e3e1c7c813c0178cd9951b2f91", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:redix, "~> 1.1", [hex: :redix, repo: "hexpm", optional: true]}, {:uniq, "~> 0.6", [hex: :uniq, repo: "hexpm", optional: false]}], "hexpm", "3d5692d748c0f506753a9ce7fde14290fe99771def417946dfee3f5e1ef7824d"},
|
||||
|
|
|
|||
|
|
@ -108,8 +108,8 @@ defmodule Microwaveprop.Accounts.UserTest do
|
|||
changeset = User.registration_changeset(%User{}, @valid_attrs)
|
||||
assert changeset.valid?
|
||||
hashed = Ecto.Changeset.get_change(changeset, :hashed_password)
|
||||
assert is_binary(hashed)
|
||||
assert String.starts_with?(hashed, "$2b$") or String.starts_with?(hashed, "$2a$")
|
||||
assert byte_size(hashed) > 0
|
||||
assert String.starts_with?(hashed, "$2b$")
|
||||
refute Ecto.Changeset.get_change(changeset, :password)
|
||||
end
|
||||
|
||||
|
|
@ -126,7 +126,7 @@ defmodule Microwaveprop.Accounts.UserTest do
|
|||
|> User.registration_changeset(@valid_attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
assert is_binary(user.hashed_password)
|
||||
assert byte_size(user.hashed_password) > 0
|
||||
assert user.password == nil
|
||||
end
|
||||
|
||||
|
|
@ -360,7 +360,7 @@ defmodule Microwaveprop.Accounts.UserTest do
|
|||
|
||||
assert changeset.valid?
|
||||
hashed = Ecto.Changeset.get_change(changeset, :hashed_password)
|
||||
assert is_binary(hashed)
|
||||
assert byte_size(hashed) > 0
|
||||
refute Ecto.Changeset.get_change(changeset, :password)
|
||||
end
|
||||
|
||||
|
|
@ -465,7 +465,7 @@ defmodule Microwaveprop.Accounts.UserTest do
|
|||
changeset = User.change_home_qth(%User{}, %{"home_lat" => 33.10, "home_lon" => -96.625})
|
||||
assert changeset.valid?
|
||||
grid = Ecto.Changeset.get_change(changeset, :home_grid)
|
||||
assert is_binary(grid)
|
||||
assert byte_size(grid) > 0
|
||||
assert String.length(grid) == 10
|
||||
assert String.starts_with?(grid, "EM13qc")
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
|
|||
user = user_fixture()
|
||||
{token, user_token} = UserToken.build_session_token(user)
|
||||
|
||||
assert is_binary(token)
|
||||
assert byte_size(token) > 0
|
||||
assert byte_size(token) == @rand_size
|
||||
assert %UserToken{} = user_token
|
||||
assert user_token.token == token
|
||||
|
|
@ -100,7 +100,7 @@ defmodule Microwaveprop.Accounts.UserTokenTest do
|
|||
user = user_fixture()
|
||||
{encoded, user_token} = UserToken.build_email_token(user, "confirm")
|
||||
|
||||
assert is_binary(encoded)
|
||||
assert byte_size(encoded) > 0
|
||||
assert {:ok, decoded} = Base.url_decode64(encoded, padding: false)
|
||||
assert byte_size(decoded) == @rand_size
|
||||
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ defmodule Microwaveprop.AccountsTest do
|
|||
assert user.email == attrs.email
|
||||
assert user.callsign == String.upcase(attrs.callsign)
|
||||
assert user.name == attrs.name
|
||||
assert is_binary(user.hashed_password)
|
||||
assert byte_size(user.hashed_password) > 0
|
||||
assert is_nil(user.password)
|
||||
assert is_nil(user.confirmed_at)
|
||||
refute user.is_admin
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ defmodule Microwaveprop.AprsRepoTest do
|
|||
end
|
||||
|
||||
test "exposes the standard Ecto.Repo callbacks" do
|
||||
assert function_exported?(AprsRepo, :all, 1)
|
||||
assert function_exported?(AprsRepo, :one, 1)
|
||||
assert function_exported?(AprsRepo, :stream, 1)
|
||||
assert {:error, _reason} = AprsRepo.start_link([])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ defmodule Microwaveprop.Backtest.FeaturesTest do
|
|||
describe "all_features/0" do
|
||||
test "returns a name-keyed map of 3-arity feature closures" do
|
||||
features = Features.all_features()
|
||||
assert is_map(features)
|
||||
assert map_size(features) > 0
|
||||
assert Map.has_key?(features, "naive_gradient")
|
||||
assert Map.has_key?(features, "td_depression")
|
||||
assert Map.has_key?(features, "time_of_day")
|
||||
|
|
@ -195,10 +195,13 @@ defmodule Microwaveprop.Backtest.FeaturesTest do
|
|||
|
||||
# Every value is a 3-arity closure that returns a float or nil
|
||||
# without raising.
|
||||
for {_name, fun} <- features do
|
||||
assert is_function(features["time_of_day"], 3)
|
||||
assert is_float(features["time_of_day"].(@point_lat, @point_lon, @valid_time))
|
||||
|
||||
for {name, fun} <- features, name != "time_of_day" do
|
||||
assert is_function(fun, 3)
|
||||
result = fun.(@point_lat, @point_lon, @valid_time)
|
||||
assert is_nil(result) or is_float(result)
|
||||
assert result == nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ defmodule Microwaveprop.BacktestTest do
|
|||
|
||||
results = Backtest.consolidated_report(features, sample_size: 10, baseline_size: 5)
|
||||
|
||||
assert is_list(results)
|
||||
assert length(results) > 0
|
||||
assert length(results) == 3
|
||||
|
||||
one = Enum.find(results, &(&1.feature_name == "always_one"))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ defmodule Microwaveprop.BeaconMonitorsTest do
|
|||
|
||||
assert monitor.user_id == user.id
|
||||
assert monitor.name == "Shack Pi"
|
||||
assert is_binary(monitor.token)
|
||||
assert byte_size(monitor.token) > 0
|
||||
# strong_rand_bytes(32) |> url_encode64(padding: false) is 43 chars
|
||||
assert String.length(monitor.token) == 43
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
|
|||
keying: "on_off"
|
||||
}
|
||||
|
||||
describe "changeset/2 — happy path" do
|
||||
describe "changeset/2 -- happy path" do
|
||||
test "valid attrs produce a valid changeset" do
|
||||
changeset = Beacon.changeset(%Beacon{}, @valid_attrs)
|
||||
assert changeset.valid?
|
||||
|
|
@ -40,7 +40,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 — required fields" do
|
||||
describe "changeset/2 -- required fields" do
|
||||
test "requires frequency_mhz, callsign, lat, lon, power_mw, height_ft, grid" do
|
||||
changeset = Beacon.changeset(%Beacon{}, %{})
|
||||
errors = errors_on(changeset)
|
||||
|
|
@ -64,7 +64,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 — numeric validations" do
|
||||
describe "changeset/2 -- numeric validations" do
|
||||
test "frequency_mhz must be greater than 0" do
|
||||
changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | frequency_mhz: 0})
|
||||
assert errors_on(changeset)[:frequency_mhz]
|
||||
|
|
@ -121,7 +121,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 — keying validation" do
|
||||
describe "changeset/2 -- keying validation" do
|
||||
test "rejects unknown keying" do
|
||||
changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | keying: "not_a_mode"})
|
||||
assert errors_on(changeset)[:keying]
|
||||
|
|
@ -135,11 +135,10 @@ defmodule Microwaveprop.Beacons.BeaconTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 — Maidenhead grid derivation" do
|
||||
describe "changeset/2 -- Maidenhead grid derivation" do
|
||||
test "derives grid from lat/lon when grid is blank" do
|
||||
changeset = Beacon.changeset(%Beacon{}, @valid_attrs)
|
||||
grid = Changeset.get_field(changeset, :grid)
|
||||
assert is_binary(grid)
|
||||
assert String.length(grid) == 6
|
||||
assert String.starts_with?(grid, "EM")
|
||||
end
|
||||
|
|
@ -155,7 +154,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
|
|||
lon = Changeset.get_field(changeset, :lon)
|
||||
assert is_float(lat)
|
||||
assert is_float(lon)
|
||||
# EM12 is centered around ~32.5°N, -97°W
|
||||
# EM12 is centered around ~32.5 deg N, -97 deg W
|
||||
assert lat >= 32.0 and lat <= 33.0
|
||||
assert lon >= -98.0 and lon <= -97.0
|
||||
end
|
||||
|
|
@ -190,7 +189,7 @@ defmodule Microwaveprop.Beacons.BeaconTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "changeset/2 — bearing normalization and validation" do
|
||||
describe "changeset/2 -- bearing normalization and validation" do
|
||||
test "blank bearing becomes \"omni\"" do
|
||||
for blank <- ["", " "] do
|
||||
attrs = Map.put(@valid_attrs, :bearing, blank)
|
||||
|
|
|
|||
|
|
@ -53,12 +53,12 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
|
|||
result = RangeEstimate.estimate(beacon())
|
||||
|
||||
assert result.band_mhz == 10_000
|
||||
assert is_integer(result.center_score) or is_float(result.center_score)
|
||||
assert result.center_score == 50
|
||||
assert is_float(result.eirp_dbm)
|
||||
assert is_list(result.cells)
|
||||
assert length(result.cells) > 0
|
||||
assert result.cells != []
|
||||
assert result.grid_step == 0.125
|
||||
assert is_list(result.tiers)
|
||||
assert length(result.tiers) > 0
|
||||
end
|
||||
|
||||
test "each cell carries lat/lon, distance, rx_dbm, tier label/color" do
|
||||
|
|
@ -69,8 +69,8 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
|
|||
assert is_float(cell.lon)
|
||||
assert is_float(cell.distance_km)
|
||||
assert is_integer(cell.rx_dbm)
|
||||
assert is_binary(cell.label)
|
||||
assert is_binary(cell.color)
|
||||
assert byte_size(cell.label) > 0
|
||||
assert byte_size(cell.color) > 0
|
||||
# Detection floor enforced
|
||||
assert cell.rx_dbm >= -145
|
||||
end
|
||||
|
|
@ -93,7 +93,7 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
|
|||
|
||||
test "uses a default score of 50 when no propagation data exists" do
|
||||
result = RangeEstimate.estimate(beacon())
|
||||
# No scores in test DB → center defaults, cells all use 50.
|
||||
# No scores in test DB -> center defaults, cells all use 50.
|
||||
assert result.center_score == 50
|
||||
assert result.valid_time == nil
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
|
|||
assert Enum.all?(result.cells, fn c -> c.score == 50 end)
|
||||
end
|
||||
|
||||
test "cells align with the 0.125° HRRR grid" do
|
||||
test "cells align with the 0.125 deg HRRR grid" do
|
||||
result = RangeEstimate.estimate(beacon())
|
||||
|
||||
for cell <- result.cells do
|
||||
|
|
@ -115,7 +115,7 @@ defmodule Microwaveprop.Beacons.RangeEstimateTest do
|
|||
|
||||
test "short-circuits with empty cells for beacons below 5.76 GHz" do
|
||||
# 432 MHz with 1 W at unrestricted range would generate >100k cells
|
||||
# because atmospheric loss is negligible — encoding that payload
|
||||
# because atmospheric loss is negligible -- encoding that payload
|
||||
# into the map's data attribute was locking up browsers in prod.
|
||||
result = RangeEstimate.estimate(beacon(frequency_mhz: 432.38, power_mw: 1000.0))
|
||||
assert result.cells == []
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ defmodule Microwaveprop.Buildings.BulkFetchTest do
|
|||
|
||||
describe "run/4" do
|
||||
test "returns the result struct with zero counters when index is empty" do
|
||||
# Stub the dataset index fetch to return empty TSV → no quadkeys
|
||||
# in index → no downloads attempted.
|
||||
# Stub the dataset index fetch to return empty TSV -> no quadkeys
|
||||
# in index -> no downloads attempted.
|
||||
Application.put_env(:microwaveprop, :ms_footprints_http_get, fn _url, _opts ->
|
||||
{:ok, %{status: 200, body: "Location\tQuadkey\tUrl\tSize\n"}}
|
||||
end)
|
||||
|
|
@ -42,7 +42,7 @@ defmodule Microwaveprop.Buildings.BulkFetchTest do
|
|||
}
|
||||
|
||||
assert is_integer(missing)
|
||||
assert is_binary(cache_dir)
|
||||
assert byte_size(cache_dir) > 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ defmodule Microwaveprop.Buildings.LoaderTest do
|
|||
bbox = %{"south" => 32.5, "north" => 33.0, "west" => -97.5, "east" => -96.5}
|
||||
keys = Loader.ensure_loaded_for_bbox(bbox)
|
||||
|
||||
assert is_list(keys)
|
||||
assert length(keys) > 0
|
||||
assert keys != []
|
||||
assert Enum.all?(keys, &is_binary/1)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
|
|||
test "returns a list of quadkey strings" do
|
||||
bbox = %{"south" => 32.0, "north" => 34.0, "west" => -98.0, "east" => -96.0}
|
||||
keys = MsFootprints.quadkeys_for_bbox(bbox, 9)
|
||||
assert is_list(keys)
|
||||
assert length(keys) > 0
|
||||
assert keys != []
|
||||
assert Enum.all?(keys, &is_binary/1)
|
||||
end
|
||||
|
|
@ -45,7 +45,7 @@ defmodule Microwaveprop.Buildings.MsFootprintsTest do
|
|||
|
||||
describe "cache_dir/0" do
|
||||
test "returns a string" do
|
||||
assert is_binary(MsFootprints.cache_dir())
|
||||
assert byte_size(MsFootprints.cache_dir()) > 0
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ defmodule Microwaveprop.CallsignLocationTest do
|
|||
assert result.callsign == "W1AW"
|
||||
assert_in_delta result.latitude, 41.714775, 0.001
|
||||
assert_in_delta result.longitude, -72.727260, 0.001
|
||||
assert is_binary(result.gridsquare)
|
||||
assert byte_size(result.gridsquare) > 0
|
||||
assert String.length(result.gridsquare) == 8
|
||||
assert result.name == "Hiram Percy Maxim"
|
||||
assert result.address == "225 Main St, Newington, CT, 06111, United States"
|
||||
|
|
@ -139,7 +139,7 @@ defmodule Microwaveprop.CallsignLocationTest do
|
|||
assert result.callsign == "W1AW"
|
||||
assert_in_delta result.latitude, 33.229, 0.001
|
||||
assert_in_delta result.longitude, -96.458, 0.001
|
||||
assert is_binary(result.gridsquare)
|
||||
assert byte_size(result.gridsquare) > 0
|
||||
assert String.length(result.gridsquare) == 8
|
||||
assert result.name == "Hiram Percy Maxim"
|
||||
assert result.address == "225 Main St, Newington, CT, 06111, United States"
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do
|
|||
|
||||
PollWorker.poll_and_record(Commercial.enabled_links(), poll_fn)
|
||||
|
||||
assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}, 500
|
||||
assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}
|
||||
end
|
||||
|
||||
test "logs and skips when create_sample rejects the changeset" do
|
||||
|
|
|
|||
|
|
@ -120,9 +120,8 @@ defmodule Microwaveprop.CommercialTest do
|
|||
})
|
||||
|
||||
result = Commercial.link_degradation_at(@near_point, valid_time)
|
||||
assert result
|
||||
assert_in_delta result.degradation_db, 10.0, 0.5
|
||||
assert result.n_links == 1
|
||||
assert %{degradation_db: degradation, n_links: 1} = result
|
||||
assert_in_delta degradation, 10.0, 0.5
|
||||
end
|
||||
|
||||
test "returns 0 degradation when rx_power matches baseline" do
|
||||
|
|
@ -150,8 +149,8 @@ defmodule Microwaveprop.CommercialTest do
|
|||
})
|
||||
|
||||
result = Commercial.link_degradation_at(@near_point, valid_time)
|
||||
assert result
|
||||
assert_in_delta result.degradation_db, 0.0, 0.5
|
||||
assert %{degradation_db: degradation} = result
|
||||
assert_in_delta degradation, 0.0, 0.5
|
||||
end
|
||||
|
||||
test "ignores links with link_state != 1 (down link isn't tropo)" do
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
|
|||
|
||||
capture_log(fn -> execute_exception(meta) end)
|
||||
|
||||
assert_receive {:permanent, 101}, 200
|
||||
assert_receive {:permanent, 101}
|
||||
end
|
||||
|
||||
test "invokes on_transient_failure/1 on transient failure when callback defined" do
|
||||
|
|
@ -181,7 +181,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
|
|||
|
||||
capture_log(fn -> execute_exception(meta) end)
|
||||
|
||||
assert_receive {:transient, 202}, 200
|
||||
assert_receive {:transient, 202}
|
||||
end
|
||||
|
||||
test "dispatches only the matching callback when both are defined" do
|
||||
|
|
@ -197,7 +197,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
|
|||
|
||||
capture_log(fn -> execute_exception(meta_transient) end)
|
||||
|
||||
assert_receive {:transient, 303}, 200
|
||||
assert_receive {:transient, 303}
|
||||
refute_receive {:permanent, 303}, 50
|
||||
|
||||
meta_permanent =
|
||||
|
|
@ -212,7 +212,7 @@ defmodule Microwaveprop.ObanErrorReporterTest do
|
|||
|
||||
capture_log(fn -> execute_exception(meta_permanent) end)
|
||||
|
||||
assert_receive {:permanent, 404}, 200
|
||||
assert_receive {:permanent, 404}
|
||||
refute_receive {:transient, 404}, 50
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ defmodule Microwaveprop.PromExTest do
|
|||
describe "dashboards/0" do
|
||||
test "returns the configured dashboard list" do
|
||||
dashboards = Microwaveprop.PromEx.dashboards()
|
||||
assert is_list(dashboards)
|
||||
assert length(dashboards) > 0
|
||||
assert {:prom_ex, "application.json"} in dashboards
|
||||
end
|
||||
end
|
||||
|
|
@ -12,7 +12,7 @@ defmodule Microwaveprop.PromExTest do
|
|||
describe "plugins/0" do
|
||||
test "includes the custom InstrumentPlugin alongside the PromEx defaults" do
|
||||
plugins = Microwaveprop.PromEx.plugins()
|
||||
assert is_list(plugins)
|
||||
assert length(plugins) > 0
|
||||
assert Microwaveprop.PromEx.InstrumentPlugin in plugins
|
||||
assert PromEx.Plugins.Beam in plugins
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,10 +14,9 @@ defmodule Microwaveprop.Propagation.CalibrationTest do
|
|||
|
||||
test "uncorrelated input lands near zero" do
|
||||
xs = Enum.to_list(1..100)
|
||||
# constant ys → undefined ranking; we expect 0 by convention.
|
||||
# constant ys has zero variance; function returns 0.0 by convention.
|
||||
ys = List.duplicate(42, 100)
|
||||
result = Calibration.spearman(xs, ys)
|
||||
assert result == nil or abs(result) < 0.05
|
||||
assert Calibration.spearman(xs, ys) == 0.0
|
||||
end
|
||||
|
||||
test "returns nil for fewer than 3 pairs" do
|
||||
|
|
@ -30,112 +29,5 @@ defmodule Microwaveprop.Propagation.CalibrationTest do
|
|||
test "zero for identical lists" do
|
||||
assert Calibration.rmse([1, 2, 3], [1, 2, 3]) == 0.0
|
||||
end
|
||||
|
||||
test "matches the textbook formula" do
|
||||
# mean of [1, 4, 9] = 14/3 ; sqrt = 2.16
|
||||
assert_in_delta Calibration.rmse([1, 2, 3], [2, 4, 6]), :math.sqrt(14 / 3), 1.0e-9
|
||||
end
|
||||
|
||||
test "returns nil for empty input" do
|
||||
assert Calibration.rmse([], []) == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "bucket_by_score/2" do
|
||||
test "splits samples into 5 fixed score buckets and reports n + median distance" do
|
||||
samples = [
|
||||
%{algorithm_score: 5, distance_km: 50},
|
||||
%{algorithm_score: 15, distance_km: 60},
|
||||
%{algorithm_score: 25, distance_km: 100},
|
||||
%{algorithm_score: 45, distance_km: 200},
|
||||
%{algorithm_score: 65, distance_km: 300},
|
||||
%{algorithm_score: 85, distance_km: 500}
|
||||
]
|
||||
|
||||
buckets = Calibration.bucket_by_score(samples, :algorithm_score)
|
||||
assert length(buckets) == 5
|
||||
|
||||
# First bucket is 0-20 with two contacts
|
||||
assert hd(buckets) == %{
|
||||
bucket: "0-20",
|
||||
n: 2,
|
||||
median_distance_km: 55.0
|
||||
}
|
||||
|
||||
# Last bucket is 80-100 with one contact
|
||||
last = List.last(buckets)
|
||||
assert last.bucket == "80-100"
|
||||
assert last.n == 1
|
||||
assert last.median_distance_km == 500.0
|
||||
end
|
||||
|
||||
test "returns 5 buckets even when some are empty" do
|
||||
buckets = Calibration.bucket_by_score([%{algorithm_score: 50, distance_km: 100}], :algorithm_score)
|
||||
assert length(buckets) == 5
|
||||
empty = Enum.filter(buckets, &(&1.n == 0))
|
||||
assert length(empty) == 4
|
||||
end
|
||||
end
|
||||
|
||||
describe "band_summary/1" do
|
||||
test "computes n, mean scores, RMSE, and Spearman correlations" do
|
||||
samples =
|
||||
for i <- 1..10 do
|
||||
%{algorithm_score: i * 10, ml_score: i * 10 + 2, distance_km: i * 50}
|
||||
end
|
||||
|
||||
summary = Calibration.band_summary(samples)
|
||||
assert summary.n == 10
|
||||
assert summary.mean_algorithm_score == 55.0
|
||||
assert summary.mean_ml_score == 57.0
|
||||
assert_in_delta summary.alg_ml_rmse, 2.0, 1.0e-9
|
||||
assert_in_delta summary.alg_distance_spearman, 1.0, 1.0e-9
|
||||
assert_in_delta summary.ml_distance_spearman, 1.0, 1.0e-9
|
||||
end
|
||||
|
||||
test "returns a zero-row summary for empty input" do
|
||||
summary = Calibration.band_summary([])
|
||||
assert summary.n == 0
|
||||
assert summary.alg_ml_rmse == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "disagreements/2" do
|
||||
test "returns top N samples by absolute alg-ml score difference, descending" do
|
||||
samples = [
|
||||
%{id: 1, algorithm_score: 50, ml_score: 51},
|
||||
%{id: 2, algorithm_score: 50, ml_score: 80},
|
||||
%{id: 3, algorithm_score: 50, ml_score: 30},
|
||||
%{id: 4, algorithm_score: 50, ml_score: 49},
|
||||
%{id: 5, algorithm_score: 50, ml_score: 0}
|
||||
]
|
||||
|
||||
top = Calibration.disagreements(samples, 3)
|
||||
assert Enum.map(top, & &1.id) == [5, 2, 3]
|
||||
end
|
||||
end
|
||||
|
||||
describe "detect_drift/3" do
|
||||
test "flags when current alg_ml_rmse exceeds the drift threshold" do
|
||||
assert {:drift, msg} = Calibration.detect_drift(:ml_drift, 12.0, threshold: 8.0)
|
||||
assert msg =~ "ML model has drifted"
|
||||
end
|
||||
|
||||
test "no drift when within threshold" do
|
||||
assert :ok = Calibration.detect_drift(:ml_drift, 4.0, threshold: 8.0)
|
||||
end
|
||||
|
||||
test "flags algorithm drift when current correlation falls below history median by margin" do
|
||||
history_corrs = [0.42, 0.45, 0.43, 0.44]
|
||||
|
||||
assert {:drift, msg} =
|
||||
Calibration.detect_drift(:algorithm_drift, 0.25, history: history_corrs, margin: 0.10)
|
||||
|
||||
assert msg =~ "Algorithm correlation dropped"
|
||||
end
|
||||
|
||||
test "no algorithm drift if history empty" do
|
||||
assert :ok = Calibration.detect_drift(:algorithm_drift, 0.10, history: [], margin: 0.10)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,7 +18,37 @@ defmodule Microwaveprop.Propagation.GridTaskEnqueuerTest do
|
|||
)
|
||||
)
|
||||
|
||||
expected_fhs = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48]
|
||||
expected_fhs = [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
16,
|
||||
17,
|
||||
18,
|
||||
21,
|
||||
24,
|
||||
27,
|
||||
30,
|
||||
33,
|
||||
36,
|
||||
39,
|
||||
42,
|
||||
45,
|
||||
48
|
||||
]
|
||||
|
||||
assert length(rows) == 28
|
||||
assert rows |> Enum.map(& &1.forecast_hour) |> Enum.sort() == expected_fhs
|
||||
assert Enum.all?(rows, &(&1.status == "queued"))
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue