prop/test/microwaveprop/propagation/score_cache_test.exs
Graham McIntire 99103e5cd6
test: PromEx + Canopy.BulkFetch + Maidenhead.valid? coverage
- 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.
2026-05-08 10:06:36 -05:00

286 lines
11 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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