- PromEx: added dashboards/0 + plugins/0 tests - Canopy.BulkFetch: 0% -> covers run/4 entry path (all tile fetches fail cleanly without network); cache/staging dirs created - Maidenhead.valid?/1: rejects integer/atom/tuple/list/map inputs - Canopy.tile_filename/2: S/E hemisphere prefix branches - ScoreCache.max_entries/0 invariant test 3624 tests, 0 failures.
286 lines
11 KiB
Elixir
286 lines
11 KiB
Elixir
defmodule Microwaveprop.Propagation.ScoreCacheTest do
|
||
use ExUnit.Case, async: false
|
||
|
||
alias Microwaveprop.Propagation.ScoreCache
|
||
|
||
setup do
|
||
ScoreCache.clear()
|
||
:ok
|
||
end
|
||
|
||
describe "fetch/2" do
|
||
test "returns :miss when nothing is cached" do
|
||
assert ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z]) == :miss
|
||
end
|
||
|
||
test "returns cached scores after put" do
|
||
scores = [%{lat: 32.0, lon: -97.0, score: 75}]
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], scores)
|
||
|
||
assert {:ok, [%{lat: 32.0, lon: -97.0, score: 75}]} =
|
||
ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
|
||
end
|
||
|
||
test "isolates entries by band" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [%{lat: 1.0, lon: 1.0, score: 10}])
|
||
ScoreCache.put(24_000, ~U[2026-04-12 12:00:00Z], [%{lat: 2.0, lon: 2.0, score: 20}])
|
||
|
||
assert {:ok, [%{score: 10}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
|
||
assert {:ok, [%{score: 20}]} = ScoreCache.fetch(24_000, ~U[2026-04-12 12:00:00Z])
|
||
end
|
||
|
||
test "isolates entries by valid_time" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [%{lat: 1.0, lon: 1.0, score: 10}])
|
||
ScoreCache.put(10_000, ~U[2026-04-12 13:00:00Z], [%{lat: 2.0, lon: 2.0, score: 20}])
|
||
|
||
assert {:ok, [%{score: 10}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
|
||
assert {:ok, [%{score: 20}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 13:00:00Z])
|
||
end
|
||
end
|
||
|
||
describe "fetch_bounds/3" do
|
||
setup do
|
||
scores = [
|
||
%{lat: 32.0, lon: -97.0, score: 75},
|
||
%{lat: 40.0, lon: -74.0, score: 50},
|
||
%{lat: 34.0, lon: -98.0, score: 60}
|
||
]
|
||
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], scores)
|
||
:ok
|
||
end
|
||
|
||
test "returns :miss when no entry for band/time" do
|
||
bounds = %{"south" => 30.0, "north" => 50.0, "west" => -100.0, "east" => -70.0}
|
||
assert ScoreCache.fetch_bounds(24_000, ~U[2026-04-12 12:00:00Z], bounds) == :miss
|
||
end
|
||
|
||
test "returns all scores when bounds is nil" do
|
||
assert {:ok, all} = ScoreCache.fetch_bounds(10_000, ~U[2026-04-12 12:00:00Z], nil)
|
||
assert length(all) == 3
|
||
end
|
||
|
||
test "returns only scores inside the bounds" do
|
||
bounds = %{"south" => 31.0, "north" => 35.0, "west" => -100.0, "east" => -95.0}
|
||
|
||
assert {:ok, filtered} =
|
||
ScoreCache.fetch_bounds(10_000, ~U[2026-04-12 12:00:00Z], bounds)
|
||
|
||
assert length(filtered) == 2
|
||
assert Enum.all?(filtered, fn %{lat: lat} -> lat >= 31.0 and lat <= 35.0 end)
|
||
end
|
||
|
||
test "includes scores exactly on the boundary" do
|
||
bounds = %{"south" => 32.0, "north" => 32.0, "west" => -97.0, "east" => -97.0}
|
||
|
||
assert {:ok, [%{lat: 32.0, lon: -97.0, score: 75}]} =
|
||
ScoreCache.fetch_bounds(10_000, ~U[2026-04-12 12:00:00Z], bounds)
|
||
end
|
||
|
||
test "returns cells across multiple 5°×5° chunks when viewport spans them" do
|
||
# Three points spanning three distinct 5°×5° chunks on the lat axis
|
||
# (~30, ~35, ~40) and three on the lon axis (~-100, ~-95, ~-75).
|
||
# A viewport covering all three must return them all even though
|
||
# each lives in its own chunk under the bucketed storage layout.
|
||
scores = [
|
||
%{lat: 31.0, lon: -99.0, score: 10},
|
||
%{lat: 36.0, lon: -94.0, score: 20},
|
||
%{lat: 41.0, lon: -74.0, score: 30}
|
||
]
|
||
|
||
ScoreCache.put(10_000, ~U[2026-04-12 13:00:00Z], scores)
|
||
|
||
bounds = %{"south" => 30.0, "north" => 45.0, "west" => -100.0, "east" => -70.0}
|
||
|
||
assert {:ok, returned} =
|
||
ScoreCache.fetch_bounds(10_000, ~U[2026-04-12 13:00:00Z], bounds)
|
||
|
||
assert Enum.sort_by(returned, & &1.score) == Enum.sort_by(scores, & &1.score)
|
||
end
|
||
|
||
test "narrow viewport excludes cells in non-overlapping chunks" do
|
||
scores = [
|
||
%{lat: 31.0, lon: -99.0, score: 10},
|
||
%{lat: 41.0, lon: -74.0, score: 30}
|
||
]
|
||
|
||
ScoreCache.put(10_000, ~U[2026-04-12 13:00:00Z], scores)
|
||
|
||
# Viewport entirely within the (lat 30-35, lon -100..-95) chunk.
|
||
bounds = %{"south" => 30.0, "north" => 33.0, "west" => -100.0, "east" => -97.0}
|
||
|
||
assert {:ok, [%{score: 10}]} =
|
||
ScoreCache.fetch_bounds(10_000, ~U[2026-04-12 13:00:00Z], bounds)
|
||
end
|
||
end
|
||
|
||
describe "prune_older_than/1" do
|
||
test "removes entries whose valid_time is strictly before the cutoff" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 10:00:00Z], [%{lat: 1.0, lon: 1.0, score: 1}])
|
||
ScoreCache.put(10_000, ~U[2026-04-12 14:00:00Z], [%{lat: 2.0, lon: 2.0, score: 2}])
|
||
|
||
ScoreCache.prune_older_than(~U[2026-04-12 12:00:00Z])
|
||
|
||
assert ScoreCache.fetch(10_000, ~U[2026-04-12 10:00:00Z]) == :miss
|
||
assert {:ok, [%{score: 2}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 14:00:00Z])
|
||
end
|
||
|
||
test "keeps entries whose valid_time equals the cutoff" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [%{lat: 1.0, lon: 1.0, score: 1}])
|
||
ScoreCache.prune_older_than(~U[2026-04-12 12:00:00Z])
|
||
assert {:ok, [%{score: 1}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
|
||
end
|
||
end
|
||
|
||
describe "prune_outside_window/2" do
|
||
test "removes entries strictly outside [past_cutoff, future_cutoff]" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 08:00:00Z], [%{lat: 1.0, lon: 1.0, score: 1}])
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [%{lat: 2.0, lon: 2.0, score: 2}])
|
||
ScoreCache.put(10_000, ~U[2026-04-12 20:00:00Z], [%{lat: 3.0, lon: 3.0, score: 3}])
|
||
|
||
removed =
|
||
ScoreCache.prune_outside_window(~U[2026-04-12 10:00:00Z], ~U[2026-04-12 18:00:00Z])
|
||
|
||
assert removed == 2
|
||
assert ScoreCache.fetch(10_000, ~U[2026-04-12 08:00:00Z]) == :miss
|
||
assert {:ok, [%{score: 2}]} = ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
|
||
assert ScoreCache.fetch(10_000, ~U[2026-04-12 20:00:00Z]) == :miss
|
||
end
|
||
|
||
test "keeps entries equal to either cutoff (inclusive bounds)" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 10:00:00Z], [%{lat: 1.0, lon: 1.0, score: 1}])
|
||
ScoreCache.put(10_000, ~U[2026-04-12 18:00:00Z], [%{lat: 2.0, lon: 2.0, score: 2}])
|
||
|
||
removed =
|
||
ScoreCache.prune_outside_window(~U[2026-04-12 10:00:00Z], ~U[2026-04-12 18:00:00Z])
|
||
|
||
assert removed == 0
|
||
assert {:ok, _} = ScoreCache.fetch(10_000, ~U[2026-04-12 10:00:00Z])
|
||
assert {:ok, _} = ScoreCache.fetch(10_000, ~U[2026-04-12 18:00:00Z])
|
||
end
|
||
end
|
||
|
||
describe "fetch_point/4" do
|
||
test "returns :miss when nothing cached" do
|
||
assert ScoreCache.fetch_point(10_000, ~U[2026-04-12 12:00:00Z], 32.0, -97.0) == :miss
|
||
end
|
||
|
||
test "returns :miss when the point is not in the cached grid" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [
|
||
%{lat: 32.0, lon: -97.0, score: 75}
|
||
])
|
||
|
||
assert ScoreCache.fetch_point(10_000, ~U[2026-04-12 12:00:00Z], 40.0, -74.0) == :miss
|
||
end
|
||
|
||
test "returns the score for a cached point" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [
|
||
%{lat: 32.0, lon: -97.0, score: 75},
|
||
%{lat: 33.0, lon: -97.0, score: 80}
|
||
])
|
||
|
||
assert {:ok, 75} = ScoreCache.fetch_point(10_000, ~U[2026-04-12 12:00:00Z], 32.0, -97.0)
|
||
assert {:ok, 80} = ScoreCache.fetch_point(10_000, ~U[2026-04-12 12:00:00Z], 33.0, -97.0)
|
||
end
|
||
end
|
||
|
||
describe "valid_times/1" do
|
||
test "returns empty list when nothing cached" do
|
||
assert ScoreCache.valid_times(10_000) == []
|
||
end
|
||
|
||
test "returns sorted valid_times for a band" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 14:00:00Z], [])
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [])
|
||
ScoreCache.put(10_000, ~U[2026-04-12 13:00:00Z], [])
|
||
|
||
assert ScoreCache.valid_times(10_000) == [
|
||
~U[2026-04-12 12:00:00Z],
|
||
~U[2026-04-12 13:00:00Z],
|
||
~U[2026-04-12 14:00:00Z]
|
||
]
|
||
end
|
||
|
||
test "only returns times for the requested band" do
|
||
ScoreCache.put(10_000, ~U[2026-04-12 12:00:00Z], [])
|
||
ScoreCache.put(24_000, ~U[2026-04-12 13:00:00Z], [])
|
||
|
||
assert ScoreCache.valid_times(10_000) == [~U[2026-04-12 12:00:00Z]]
|
||
assert ScoreCache.valid_times(24_000) == [~U[2026-04-12 13:00:00Z]]
|
||
end
|
||
end
|
||
|
||
describe "broadcast_put/3" do
|
||
test "populates the cache on the local node" do
|
||
scores = [%{lat: 32.0, lon: -97.0, score: 80}]
|
||
ScoreCache.broadcast_put(10_000, ~U[2026-04-12 12:00:00Z], scores)
|
||
ScoreCache.sync()
|
||
|
||
assert {:ok, [%{lat: 32.0, lon: -97.0, score: 80}]} =
|
||
ScoreCache.fetch(10_000, ~U[2026-04-12 12:00:00Z])
|
||
end
|
||
end
|
||
|
||
describe "max_entries/0" do
|
||
test "returns the configured cap as a positive integer" do
|
||
assert is_integer(ScoreCache.max_entries())
|
||
assert ScoreCache.max_entries() > 0
|
||
end
|
||
end
|
||
|
||
describe "max-entries eviction" do
|
||
test "caps entries at max_entries, evicting the oldest valid_time" do
|
||
cap = ScoreCache.max_entries()
|
||
base = ~U[2026-04-12 00:00:00Z]
|
||
score = [%{lat: 1.0, lon: 1.0, score: 1}]
|
||
|
||
# Insert cap+1 entries, each at a distinct valid_time, oldest first.
|
||
for hour <- 0..cap do
|
||
ScoreCache.put(10_000, DateTime.add(base, hour * 3600, :second), score)
|
||
end
|
||
|
||
# Size never exceeds the cap.
|
||
assert :ets.info(:propagation_score_cache, :size) == cap
|
||
|
||
# The oldest valid_time was evicted.
|
||
assert ScoreCache.fetch(10_000, base) == :miss
|
||
|
||
# The newest is retained.
|
||
newest = DateTime.add(base, cap * 3600, :second)
|
||
assert {:ok, [%{score: 1}]} = ScoreCache.fetch(10_000, newest)
|
||
end
|
||
|
||
test "evicts across bands by valid_time when at capacity" do
|
||
cap = ScoreCache.max_entries()
|
||
base = ~U[2026-04-12 00:00:00Z]
|
||
score = [%{lat: 1.0, lon: 1.0, score: 1}]
|
||
|
||
# Fill cap entries on band A at older valid_times.
|
||
for hour <- 0..(cap - 1) do
|
||
ScoreCache.put(10_000, DateTime.add(base, hour * 3600, :second), score)
|
||
end
|
||
|
||
assert :ets.info(:propagation_score_cache, :size) == cap
|
||
|
||
# One more on band B at a newer valid_time should evict the oldest
|
||
# band-A entry, not the new band-B one.
|
||
newer = DateTime.add(base, cap * 3600, :second)
|
||
ScoreCache.put(24_000, newer, score)
|
||
|
||
assert :ets.info(:propagation_score_cache, :size) == cap
|
||
assert ScoreCache.fetch(10_000, base) == :miss
|
||
assert {:ok, _} = ScoreCache.fetch(24_000, newer)
|
||
end
|
||
|
||
test "replacing an existing key does not change the cache size" do
|
||
base = ~U[2026-04-12 00:00:00Z]
|
||
ScoreCache.put(10_000, base, [%{lat: 1.0, lon: 1.0, score: 1}])
|
||
size_before = :ets.info(:propagation_score_cache, :size)
|
||
|
||
ScoreCache.put(10_000, base, [%{lat: 1.0, lon: 1.0, score: 99}])
|
||
|
||
assert :ets.info(:propagation_score_cache, :size) == size_before
|
||
assert {:ok, [%{score: 99}]} = ScoreCache.fetch(10_000, base)
|
||
end
|
||
end
|
||
end
|