refactor: pattern-match over conditionals in Navigation and InsertOptimizer
Swaps `if`/`cond` branches for multi-clause function heads or head-pattern matching, making the control flow visible in the function signatures: - Navigation.determine_map_location: resolve_location/4 now dispatches on the shape of the geolocation input and whether URL params are explicit. - Navigation.handle_callsign_tracking: per-case clauses for empty callsign and explicit-URL-params short-circuits, plus a final clause that does the DB lookup and rescues once. - Navigation.handle_callsign_search: dispatch_callsign_search/3 separates the valid/invalid callsign branches. - InsertOptimizer.optimize_based_on_performance: pattern-match on [_, _, _ | _] to require ≥3 samples without calling length/1. - InsertOptimizer.calculate_insert_options and throughput helpers split into guarded clauses instead of inline `if`. - maybe_emit_optimization uses same-var pattern matching to skip the telemetry hit when nothing changed. Adds unit tests covering every branch of both modules plus SignalHandler, LocaleHook, and the InsertOptimizer's :noproc fallback path. Coverage 64.69% → 65.72%.
This commit is contained in:
parent
cbf9a9bb5c
commit
c5a82b77c1
6 changed files with 457 additions and 87 deletions
|
|
@ -75,14 +75,11 @@ defmodule Aprsme.Performance.InsertOptimizer do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_cast({:record_metrics, batch_size, duration_ms, success_count}, state) do
|
def handle_cast({:record_metrics, batch_size, duration_ms, success_count}, state) do
|
||||||
# Calculate throughput (packets per second)
|
|
||||||
throughput = if duration_ms > 0, do: success_count / (duration_ms / 1000), else: 0
|
|
||||||
|
|
||||||
metric = %{
|
metric = %{
|
||||||
batch_size: batch_size,
|
batch_size: batch_size,
|
||||||
duration_ms: duration_ms,
|
duration_ms: duration_ms,
|
||||||
success_count: success_count,
|
success_count: success_count,
|
||||||
throughput: throughput,
|
throughput: throughput(success_count, duration_ms),
|
||||||
timestamp: System.monotonic_time(:millisecond)
|
timestamp: System.monotonic_time(:millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,6 +89,11 @@ defmodule Aprsme.Performance.InsertOptimizer do
|
||||||
{:noreply, %{state | performance_history: new_history}}
|
{:noreply, %{state | performance_history: new_history}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Packets-per-second, or 0 when the batch completed instantly (avoid div-by-0).
|
||||||
|
defp throughput(_count, 0), do: 0
|
||||||
|
defp throughput(_count, duration_ms) when duration_ms < 0, do: 0
|
||||||
|
defp throughput(count, duration_ms), do: count / (duration_ms / 1000)
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info(:optimize_settings, state) do
|
def handle_info(:optimize_settings, state) do
|
||||||
new_state = optimize_based_on_performance(state)
|
new_state = optimize_based_on_performance(state)
|
||||||
|
|
@ -103,43 +105,44 @@ defmodule Aprsme.Performance.InsertOptimizer do
|
||||||
Process.send_after(self(), :optimize_settings, @optimization_check_interval)
|
Process.send_after(self(), :optimize_settings, @optimization_check_interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp optimize_based_on_performance(state) do
|
# Require at least three samples before doing anything — match directly on the
|
||||||
if length(state.performance_history) >= 3 do
|
# list prefix instead of calling length/1 so we short-circuit for short lists.
|
||||||
# Analyze recent performance
|
defp optimize_based_on_performance(%{performance_history: [_, _, _ | _] = history} = state) do
|
||||||
recent_metrics = Enum.take(state.performance_history, 5)
|
recent_metrics = Enum.take(history, 5)
|
||||||
total_throughput = recent_metrics |> Enum.map(& &1.throughput) |> Enum.sum()
|
sample_size = length(recent_metrics)
|
||||||
avg_throughput = total_throughput / length(recent_metrics)
|
avg_throughput = average(recent_metrics, & &1.throughput, sample_size)
|
||||||
total_duration = recent_metrics |> Enum.map(& &1.duration_ms) |> Enum.sum()
|
avg_duration = average(recent_metrics, & &1.duration_ms, sample_size)
|
||||||
avg_duration = total_duration / length(recent_metrics)
|
|
||||||
|
|
||||||
# Adjust batch size based on performance
|
new_batch_size = calculate_optimal_batch_size(avg_throughput, avg_duration, state.current_batch_size)
|
||||||
new_batch_size = calculate_optimal_batch_size(avg_throughput, avg_duration, state.current_batch_size)
|
new_insert_options = calculate_insert_options(avg_duration)
|
||||||
|
|
||||||
# Adjust INSERT options based on system load
|
Logger.debug(
|
||||||
new_insert_options = calculate_insert_options(avg_duration)
|
"INSERT optimization: batch_size=#{new_batch_size}, avg_throughput=#{Float.round(avg_throughput, 2)} pps"
|
||||||
|
)
|
||||||
|
|
||||||
Logger.debug(
|
:telemetry.execute([:aprsme, :insert_optimizer, :batch_size], %{value: new_batch_size}, %{})
|
||||||
"INSERT optimization: batch_size=#{new_batch_size}, avg_throughput=#{Float.round(avg_throughput, 2)} pps"
|
:telemetry.execute([:aprsme, :insert_optimizer, :throughput], %{value: avg_throughput}, %{})
|
||||||
)
|
:telemetry.execute([:aprsme, :insert_optimizer, :duration], %{value: avg_duration}, %{})
|
||||||
|
maybe_emit_optimization(new_batch_size, state.current_batch_size)
|
||||||
|
|
||||||
# Emit telemetry for monitoring
|
%{
|
||||||
:telemetry.execute([:aprsme, :insert_optimizer, :batch_size], %{value: new_batch_size}, %{})
|
|
||||||
:telemetry.execute([:aprsme, :insert_optimizer, :throughput], %{value: avg_throughput}, %{})
|
|
||||||
:telemetry.execute([:aprsme, :insert_optimizer, :duration], %{value: avg_duration}, %{})
|
|
||||||
|
|
||||||
if new_batch_size != state.current_batch_size do
|
|
||||||
:telemetry.execute([:aprsme, :insert_optimizer, :optimizations], %{count: 1}, %{})
|
|
||||||
end
|
|
||||||
|
|
||||||
%{
|
|
||||||
state
|
|
||||||
| current_batch_size: new_batch_size,
|
|
||||||
insert_options: new_insert_options,
|
|
||||||
last_optimization: System.monotonic_time(:millisecond)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
state
|
state
|
||||||
end
|
| current_batch_size: new_batch_size,
|
||||||
|
insert_options: new_insert_options,
|
||||||
|
last_optimization: System.monotonic_time(:millisecond)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp optimize_based_on_performance(state), do: state
|
||||||
|
|
||||||
|
defp average(items, getter, count) do
|
||||||
|
items |> Enum.map(getter) |> Enum.sum() |> Kernel./(count)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_emit_optimization(same, same), do: :ok
|
||||||
|
|
||||||
|
defp maybe_emit_optimization(_new, _old) do
|
||||||
|
:telemetry.execute([:aprsme, :insert_optimizer, :optimizations], %{count: 1}, %{})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp calculate_optimal_batch_size(avg_throughput, avg_duration, current_batch_size) do
|
defp calculate_optimal_batch_size(avg_throughput, avg_duration, current_batch_size) do
|
||||||
|
|
@ -158,21 +161,17 @@ defmodule Aprsme.Performance.InsertOptimizer do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp calculate_insert_options(avg_duration) do
|
# When INSERTs are slow (>3s avg), use a longer timeout; otherwise stick with defaults.
|
||||||
base_options = default_insert_options()
|
defp calculate_insert_options(avg_duration) when avg_duration > 3_000 do
|
||||||
|
Keyword.merge(default_insert_options(),
|
||||||
# If INSERTs are taking too long, use more aggressive optimizations
|
returning: false,
|
||||||
if avg_duration > 3000 do
|
on_conflict: :nothing,
|
||||||
Keyword.merge(base_options,
|
timeout: 30_000
|
||||||
returning: false,
|
)
|
||||||
on_conflict: :nothing,
|
|
||||||
timeout: 30_000
|
|
||||||
)
|
|
||||||
else
|
|
||||||
base_options
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp calculate_insert_options(_avg_duration), do: default_insert_options()
|
||||||
|
|
||||||
defp default_insert_options do
|
defp default_insert_options do
|
||||||
[
|
[
|
||||||
# Don't return IDs unless needed
|
# Don't return IDs unless needed
|
||||||
|
|
|
||||||
|
|
@ -21,25 +21,32 @@ defmodule AprsmeWeb.MapLive.Navigation do
|
||||||
def determine_map_location(params, session) do
|
def determine_map_location(params, session) do
|
||||||
{url_center, url_zoom} = UrlParams.parse_map_params(params)
|
{url_center, url_zoom} = UrlParams.parse_map_params(params)
|
||||||
has_explicit_url_params = UrlParams.has_explicit_url_params?(params)
|
has_explicit_url_params = UrlParams.has_explicit_url_params?(params)
|
||||||
|
geo = session["ip_geolocation"]
|
||||||
|
|
||||||
Logger.info(
|
Logger.info(
|
||||||
"determine_map_location: session ip_geolocation=#{inspect(session["ip_geolocation"])}, has_explicit_url_params=#{has_explicit_url_params}"
|
"determine_map_location: session ip_geolocation=#{inspect(geo)}, has_explicit_url_params=#{has_explicit_url_params}"
|
||||||
)
|
)
|
||||||
|
|
||||||
case session["ip_geolocation"] do
|
resolve_location(geo, has_explicit_url_params, url_center, url_zoom)
|
||||||
%{"lat" => lat, "lng" => lng} when is_number(lat) and is_number(lng) ->
|
end
|
||||||
if has_explicit_url_params do
|
|
||||||
Logger.info("Using URL params over geolocation: center=#{inspect(url_center)}, zoom=#{url_zoom}")
|
|
||||||
{url_center, url_zoom, false}
|
|
||||||
else
|
|
||||||
Logger.info("Using IP geolocation: lat=#{lat}, lng=#{lng}, zoom=11")
|
|
||||||
{%{lat: lat, lng: lng}, 11, true}
|
|
||||||
end
|
|
||||||
|
|
||||||
_ ->
|
# Explicit URL params always win over geolocation.
|
||||||
Logger.info("No IP geolocation in session, using defaults")
|
defp resolve_location(_geo, true, url_center, url_zoom) do
|
||||||
{url_center, url_zoom, !has_explicit_url_params}
|
Logger.info("Using URL params over geolocation: center=#{inspect(url_center)}, zoom=#{url_zoom}")
|
||||||
end
|
{url_center, url_zoom, false}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Valid geolocation + no explicit URL params → use the geolocation.
|
||||||
|
defp resolve_location(%{"lat" => lat, "lng" => lng}, false, _url_center, _url_zoom)
|
||||||
|
when is_number(lat) and is_number(lng) do
|
||||||
|
Logger.info("Using IP geolocation: lat=#{lat}, lng=#{lng}, zoom=11")
|
||||||
|
{%{lat: lat, lng: lng}, 11, true}
|
||||||
|
end
|
||||||
|
|
||||||
|
# No usable geolocation → fall back to URL/defaults.
|
||||||
|
defp resolve_location(_geo, has_explicit_url_params, url_center, url_zoom) do
|
||||||
|
Logger.info("No IP geolocation in session, using defaults")
|
||||||
|
{url_center, url_zoom, !has_explicit_url_params}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -47,27 +54,20 @@ defmodule AprsmeWeb.MapLive.Navigation do
|
||||||
Returns {final_map_center, final_map_zoom}.
|
Returns {final_map_center, final_map_zoom}.
|
||||||
"""
|
"""
|
||||||
@spec handle_callsign_tracking(binary(), map(), integer(), boolean()) :: {map(), integer()}
|
@spec handle_callsign_tracking(binary(), map(), integer(), boolean()) :: {map(), integer()}
|
||||||
def handle_callsign_tracking(tracked_callsign, map_center, map_zoom, has_explicit_url_params) do
|
def handle_callsign_tracking("", map_center, map_zoom, _), do: {map_center, map_zoom}
|
||||||
if tracked_callsign != "" and not has_explicit_url_params do
|
def handle_callsign_tracking(_, map_center, map_zoom, true), do: {map_center, map_zoom}
|
||||||
try do
|
|
||||||
case Packets.get_latest_packet_for_callsign(tracked_callsign) do
|
|
||||||
%{lat: lat, lon: lon} when is_number(lat) and is_number(lon) ->
|
|
||||||
{%{lat: lat, lng: lon}, 12}
|
|
||||||
|
|
||||||
_ ->
|
def handle_callsign_tracking(tracked_callsign, map_center, map_zoom, false) do
|
||||||
{map_center, map_zoom}
|
case Packets.get_latest_packet_for_callsign(tracked_callsign) do
|
||||||
end
|
%{lat: lat, lon: lon} when is_number(lat) and is_number(lon) ->
|
||||||
rescue
|
{%{lat: lat, lng: lon}, 12}
|
||||||
# Handle database connection errors gracefully (especially in tests)
|
|
||||||
DBConnection.OwnershipError ->
|
|
||||||
{map_center, map_zoom}
|
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
{map_center, map_zoom}
|
{map_center, map_zoom}
|
||||||
end
|
|
||||||
else
|
|
||||||
{map_center, map_zoom}
|
|
||||||
end
|
end
|
||||||
|
rescue
|
||||||
|
# Handle database connection errors gracefully (especially in tests).
|
||||||
|
_error -> {map_center, map_zoom}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -77,12 +77,15 @@ defmodule AprsmeWeb.MapLive.Navigation do
|
||||||
def handle_callsign_search("", socket), do: {:noreply, socket}
|
def handle_callsign_search("", socket), do: {:noreply, socket}
|
||||||
|
|
||||||
def handle_callsign_search(callsign, socket) do
|
def handle_callsign_search(callsign, socket) do
|
||||||
if ParamUtils.valid_callsign?(callsign) do
|
dispatch_callsign_search(ParamUtils.valid_callsign?(callsign), callsign, socket)
|
||||||
# Navigate to the new URL structure with the callsign as a path param
|
end
|
||||||
{:noreply, LiveView.push_navigate(socket, to: "/#{callsign}")}
|
|
||||||
else
|
defp dispatch_callsign_search(true, callsign, socket) do
|
||||||
{:noreply, LiveView.put_flash(socket, :error, "Invalid callsign format")}
|
{:noreply, LiveView.push_navigate(socket, to: "/#{callsign}")}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp dispatch_callsign_search(false, _callsign, socket) do
|
||||||
|
{:noreply, LiveView.put_flash(socket, :error, "Invalid callsign format")}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
153
test/aprsme/performance/insert_optimizer_test.exs
Normal file
153
test/aprsme/performance/insert_optimizer_test.exs
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
defmodule Aprsme.Performance.InsertOptimizerTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Aprsme.Performance.InsertOptimizer
|
||||||
|
|
||||||
|
defp fresh_state do
|
||||||
|
%{
|
||||||
|
current_batch_size: 200,
|
||||||
|
insert_options: [returning: false, on_conflict: :nothing, timeout: 15_000],
|
||||||
|
performance_history: [],
|
||||||
|
last_optimization: System.monotonic_time(:millisecond)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp metric(throughput, duration_ms) do
|
||||||
|
%{
|
||||||
|
batch_size: 200,
|
||||||
|
duration_ms: duration_ms,
|
||||||
|
success_count: 100,
|
||||||
|
throughput: throughput,
|
||||||
|
timestamp: System.monotonic_time(:millisecond)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GenServer callbacks" do
|
||||||
|
test "handle_call :get_batch_size returns current batch size" do
|
||||||
|
state = fresh_state()
|
||||||
|
|
||||||
|
assert {:reply, 200, ^state} =
|
||||||
|
InsertOptimizer.handle_call(:get_batch_size, self(), state)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handle_call :get_insert_options returns insert_options" do
|
||||||
|
state = fresh_state()
|
||||||
|
opts = state.insert_options
|
||||||
|
assert {:reply, ^opts, ^state} = InsertOptimizer.handle_call(:get_insert_options, self(), state)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handle_cast records a new metric in performance_history" do
|
||||||
|
state = fresh_state()
|
||||||
|
|
||||||
|
{:noreply, new_state} =
|
||||||
|
InsertOptimizer.handle_cast({:record_metrics, 200, 1_000, 100}, state)
|
||||||
|
|
||||||
|
assert [entry | _] = new_state.performance_history
|
||||||
|
assert entry.batch_size == 200
|
||||||
|
assert entry.duration_ms == 1_000
|
||||||
|
assert entry.success_count == 100
|
||||||
|
# Throughput = 100 / (1000/1000) = 100 pps
|
||||||
|
assert entry.throughput == 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "handle_cast with zero duration avoids div-by-0, throughput is 0" do
|
||||||
|
state = fresh_state()
|
||||||
|
|
||||||
|
{:noreply, new_state} =
|
||||||
|
InsertOptimizer.handle_cast({:record_metrics, 100, 0, 50}, state)
|
||||||
|
|
||||||
|
assert [entry | _] = new_state.performance_history
|
||||||
|
assert entry.throughput == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "performance history is capped at 20 entries" do
|
||||||
|
state = fresh_state()
|
||||||
|
|
||||||
|
final =
|
||||||
|
Enum.reduce(1..25, state, fn _, s ->
|
||||||
|
{:noreply, s2} = InsertOptimizer.handle_cast({:record_metrics, 200, 1000, 100}, s)
|
||||||
|
s2
|
||||||
|
end)
|
||||||
|
|
||||||
|
assert length(final.performance_history) == 20
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_info :optimize_settings" do
|
||||||
|
test "with fewer than 3 metrics, state is unchanged except for re-scheduled timer" do
|
||||||
|
state = %{fresh_state() | performance_history: [metric(100, 1000), metric(90, 1100)]}
|
||||||
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
||||||
|
# Batch size shouldn't have moved.
|
||||||
|
assert new_state.current_batch_size == state.current_batch_size
|
||||||
|
assert new_state.performance_history == state.performance_history
|
||||||
|
end
|
||||||
|
|
||||||
|
test "grows batch size when throughput is high and duration low" do
|
||||||
|
# With avg_throughput > 200 and avg_duration < 2000, batch size should scale up
|
||||||
|
# from 200 → min(@max, round(200 * 1.2)) = 240.
|
||||||
|
history =
|
||||||
|
Enum.map(1..5, fn _ -> metric(300, 1000) end)
|
||||||
|
|
||||||
|
state = %{fresh_state() | performance_history: history}
|
||||||
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
||||||
|
assert new_state.current_batch_size == 240
|
||||||
|
# Fast INSERTs keep default options.
|
||||||
|
assert Keyword.get(new_state.insert_options, :timeout) == 15_000
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shrinks batch size when throughput is low and duration high" do
|
||||||
|
# avg_throughput < 50 and avg_duration > 5000 → batch_size drops by 20%.
|
||||||
|
history = Enum.map(1..5, fn _ -> metric(10, 6000) end)
|
||||||
|
state = %{fresh_state() | performance_history: history}
|
||||||
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
||||||
|
assert new_state.current_batch_size == 160
|
||||||
|
# Slow INSERTs bump timeout.
|
||||||
|
assert Keyword.get(new_state.insert_options, :timeout) == 30_000
|
||||||
|
end
|
||||||
|
|
||||||
|
test "keeps batch size when performance is middling" do
|
||||||
|
# 100 pps at 3000ms falls in neither bucket → no change to batch size.
|
||||||
|
history = Enum.map(1..5, fn _ -> metric(100, 3000) end)
|
||||||
|
state = %{fresh_state() | performance_history: history}
|
||||||
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
||||||
|
assert new_state.current_batch_size == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "never drops below the minimum batch size" do
|
||||||
|
state = %{
|
||||||
|
fresh_state()
|
||||||
|
| current_batch_size: 110,
|
||||||
|
performance_history: Enum.map(1..5, fn _ -> metric(5, 10_000) end)
|
||||||
|
}
|
||||||
|
|
||||||
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
||||||
|
assert new_state.current_batch_size == 100
|
||||||
|
end
|
||||||
|
|
||||||
|
test "never exceeds the maximum batch size" do
|
||||||
|
state = %{
|
||||||
|
fresh_state()
|
||||||
|
| current_batch_size: 750,
|
||||||
|
performance_history: Enum.map(1..5, fn _ -> metric(500, 500) end)
|
||||||
|
}
|
||||||
|
|
||||||
|
{:noreply, new_state} = InsertOptimizer.handle_info(:optimize_settings, state)
|
||||||
|
assert new_state.current_batch_size == 800
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "client API when GenServer isn't running" do
|
||||||
|
test "get_optimal_batch_size falls back to base size on :noproc" do
|
||||||
|
# The supervised instance isn't started in the app tree, so this should
|
||||||
|
# hit the catch clause and return the base batch size (200).
|
||||||
|
assert InsertOptimizer.get_optimal_batch_size() == 200
|
||||||
|
end
|
||||||
|
|
||||||
|
test "get_insert_options falls back to default options on :noproc" do
|
||||||
|
opts = InsertOptimizer.get_insert_options()
|
||||||
|
assert Keyword.get(opts, :returning) == false
|
||||||
|
assert Keyword.get(opts, :on_conflict) == :nothing
|
||||||
|
assert Keyword.get(opts, :timeout) == 15_000
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
16
test/aprsme/signal_handler_test.exs
Normal file
16
test/aprsme/signal_handler_test.exs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
defmodule Aprsme.SignalHandlerTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Aprsme.SignalHandler
|
||||||
|
|
||||||
|
describe "handle_info/2" do
|
||||||
|
test "ignores unrecognized messages without changing state" do
|
||||||
|
state = %{some: :state}
|
||||||
|
assert SignalHandler.handle_info(:random, state) == {:noreply, state}
|
||||||
|
assert SignalHandler.handle_info({:other, "msg"}, state) == {:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
# We can't safely exercise the :sigterm clause here — it calls
|
||||||
|
# Aprsme.ShutdownHandler.shutdown() which would actually shut the app down.
|
||||||
|
end
|
||||||
|
end
|
||||||
87
test/aprsme_web/live/locale_hook_test.exs
Normal file
87
test/aprsme_web/live/locale_hook_test.exs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
defmodule AprsmeWeb.LocaleHookTest do
|
||||||
|
# on_mount/4 mutates the process-wide Gettext locale and reads app env;
|
||||||
|
# serialize with anything else that does the same.
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias AprsmeWeb.LocaleHook
|
||||||
|
alias Phoenix.LiveView.Socket
|
||||||
|
|
||||||
|
setup do
|
||||||
|
original_env = Application.get_env(:aprsme, :env)
|
||||||
|
original_locale = Gettext.get_locale(AprsmeWeb.Gettext)
|
||||||
|
|
||||||
|
on_exit(fn ->
|
||||||
|
Application.put_env(:aprsme, :env, original_env)
|
||||||
|
Gettext.put_locale(AprsmeWeb.Gettext, original_locale)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp bare_socket do
|
||||||
|
%Socket{
|
||||||
|
assigns: %{__changed__: %{}},
|
||||||
|
private: %{}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "on_mount/4 in production" do
|
||||||
|
setup do
|
||||||
|
Application.put_env(:aprsme, :env, :prod)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets a supported locale from session" do
|
||||||
|
{:cont, socket} = LocaleHook.on_mount(:set_locale, %{}, %{"locale" => "es"}, bare_socket())
|
||||||
|
assert socket.assigns.locale == "es"
|
||||||
|
assert Gettext.get_locale(AprsmeWeb.Gettext) == "es"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to 'en' for unsupported session locale" do
|
||||||
|
{:cont, socket} = LocaleHook.on_mount(:set_locale, %{}, %{"locale" => "zz"}, bare_socket())
|
||||||
|
assert socket.assigns.locale == "en"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to 'en' when session has no locale" do
|
||||||
|
{:cont, socket} = LocaleHook.on_mount(:set_locale, %{}, %{}, bare_socket())
|
||||||
|
assert socket.assigns.locale == "en"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts all declared supported locales" do
|
||||||
|
for loc <- ["en", "es", "de", "fr"] do
|
||||||
|
{:cont, socket} =
|
||||||
|
LocaleHook.on_mount(:set_locale, %{}, %{"locale" => loc}, bare_socket())
|
||||||
|
|
||||||
|
assert socket.assigns.locale == loc
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "on_mount/4 in non-prod" do
|
||||||
|
test "leaves locale assign as nil" do
|
||||||
|
Application.put_env(:aprsme, :env, :dev)
|
||||||
|
{:cont, socket} = LocaleHook.on_mount(:set_locale, %{}, %{"locale" => "es"}, bare_socket())
|
||||||
|
assert socket.assigns.locale == nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "on_mount/4 map_page assign" do
|
||||||
|
test "map_page is false for non-MapLive socket" do
|
||||||
|
Application.put_env(:aprsme, :env, :dev)
|
||||||
|
{:cont, socket} = LocaleHook.on_mount(:set_locale, %{}, %{}, bare_socket())
|
||||||
|
assert socket.assigns.map_page == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "map_page is true when the socket's view is MapLive.Index" do
|
||||||
|
Application.put_env(:aprsme, :env, :dev)
|
||||||
|
|
||||||
|
socket = %Socket{
|
||||||
|
assigns: %{__changed__: %{}},
|
||||||
|
private: %{phoenix_live_view: %{view: AprsmeWeb.MapLive.Index}}
|
||||||
|
}
|
||||||
|
|
||||||
|
{:cont, socket} = LocaleHook.on_mount(:set_locale, %{}, %{}, socket)
|
||||||
|
assert socket.assigns.map_page == true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
112
test/aprsme_web/live/map_live/navigation_test.exs
Normal file
112
test/aprsme_web/live/map_live/navigation_test.exs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
defmodule AprsmeWeb.MapLive.NavigationTest do
|
||||||
|
# Logger.info calls are fine during tests; we don't silence them.
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias AprsmeWeb.MapLive.Navigation
|
||||||
|
alias Phoenix.LiveView.Socket
|
||||||
|
|
||||||
|
describe "determine_map_location/2" do
|
||||||
|
test "explicit URL params win over IP geolocation" do
|
||||||
|
params = %{"lat" => "40.0", "lng" => "-95.0", "z" => "7"}
|
||||||
|
session = %{"ip_geolocation" => %{"lat" => 10.0, "lng" => 20.0}}
|
||||||
|
|
||||||
|
assert {%{lat: 40.0, lng: -95.0}, 7, false} =
|
||||||
|
Navigation.determine_map_location(params, session)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "valid IP geolocation used when no explicit URL params" do
|
||||||
|
session = %{"ip_geolocation" => %{"lat" => 45.5, "lng" => -122.6}}
|
||||||
|
assert {%{lat: 45.5, lng: -122.6}, 11, true} = Navigation.determine_map_location(%{}, session)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "missing or invalid geolocation falls back to URL defaults" do
|
||||||
|
# No geolocation and no URL params → default center + skip_initial_url_update=true
|
||||||
|
assert {%{lat: _, lng: _}, _zoom, true} = Navigation.determine_map_location(%{}, %{})
|
||||||
|
|
||||||
|
# Geolocation present but with non-numeric values → fall back
|
||||||
|
session = %{"ip_geolocation" => %{"lat" => "not-a-number", "lng" => "nope"}}
|
||||||
|
assert {%{lat: _, lng: _}, _zoom, true} = Navigation.determine_map_location(%{}, session)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "URL params win even with malformed geolocation" do
|
||||||
|
session = %{"ip_geolocation" => %{"lat" => "oops", "lng" => "oops"}}
|
||||||
|
params = %{"lat" => "10.0"}
|
||||||
|
{_, _, skip?} = Navigation.determine_map_location(params, session)
|
||||||
|
assert skip? == false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_callsign_tracking/4" do
|
||||||
|
@default_center %{lat: 10.0, lng: 20.0}
|
||||||
|
@default_zoom 5
|
||||||
|
|
||||||
|
test "empty callsign returns unchanged center and zoom" do
|
||||||
|
assert Navigation.handle_callsign_tracking("", @default_center, @default_zoom, false) ==
|
||||||
|
{@default_center, @default_zoom}
|
||||||
|
|
||||||
|
assert Navigation.handle_callsign_tracking("", @default_center, @default_zoom, true) ==
|
||||||
|
{@default_center, @default_zoom}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "when explicit URL params are set, tracking is bypassed" do
|
||||||
|
assert Navigation.handle_callsign_tracking("K5ABC", @default_center, @default_zoom, true) ==
|
||||||
|
{@default_center, @default_zoom}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns defaults when the database lookup raises" do
|
||||||
|
# With a callsign that won't match any test data and no DB ownership,
|
||||||
|
# the rescue clause returns the defaults.
|
||||||
|
assert Navigation.handle_callsign_tracking(
|
||||||
|
"NONEXISTENT-99",
|
||||||
|
@default_center,
|
||||||
|
@default_zoom,
|
||||||
|
false
|
||||||
|
) == {@default_center, @default_zoom}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "handle_callsign_search/2" do
|
||||||
|
test "empty string returns the socket untouched" do
|
||||||
|
socket = %Socket{assigns: %{__changed__: %{}}}
|
||||||
|
assert {:noreply, ^socket} = Navigation.handle_callsign_search("", socket)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "invalid callsign puts a flash error" do
|
||||||
|
socket = %Socket{assigns: %{__changed__: %{}, flash: %{}}}
|
||||||
|
{:noreply, result} = Navigation.handle_callsign_search("HELLO WORLD", socket)
|
||||||
|
assert result.assigns.flash["error"] == "Invalid callsign format"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "valid callsign triggers push_navigate" do
|
||||||
|
socket = %Socket{
|
||||||
|
assigns: %{__changed__: %{}, flash: %{}},
|
||||||
|
redirected: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
{:noreply, result} = Navigation.handle_callsign_search("K5ABC-9", socket)
|
||||||
|
# push_navigate/2 sets the `redirected` field on the socket.
|
||||||
|
assert result.redirected
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "update_and_zoom_to_location/4" do
|
||||||
|
test "sets map_center and map_zoom assigns, and pushes a zoom event" do
|
||||||
|
socket = %Socket{assigns: %{__changed__: %{}}}
|
||||||
|
result = Navigation.update_and_zoom_to_location(socket, 30.5, -95.0, 8)
|
||||||
|
assert result.assigns.map_center == %{lat: 30.5, lng: -95.0}
|
||||||
|
assert result.assigns.map_zoom == 8
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "zoom_to_current_location/1" do
|
||||||
|
test "pushes a zoom_to_location event using the socket's center/zoom" do
|
||||||
|
socket = %Socket{
|
||||||
|
assigns: %{__changed__: %{}, map_center: %{lat: 1.0, lng: 2.0}, map_zoom: 10}
|
||||||
|
}
|
||||||
|
|
||||||
|
# The function returns the socket (with push_event recorded).
|
||||||
|
result = Navigation.zoom_to_current_location(socket)
|
||||||
|
assert %Socket{} = result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue