147 lines
5.1 KiB
Elixir
147 lines
5.1 KiB
Elixir
defmodule Microwaveprop.Propagation.BandConfigPropertyTest do
|
|
@moduledoc """
|
|
Property tests for `Microwaveprop.Propagation.BandConfig` —
|
|
exercises the data-driven invariants that the scorer relies on
|
|
(every band has the right shape; weights sum to 1.0; seasonal
|
|
tables cover all 12 months).
|
|
"""
|
|
|
|
use ExUnit.Case, async: true
|
|
use ExUnitProperties
|
|
|
|
alias Microwaveprop.Propagation.BandConfig
|
|
|
|
defp band_config_gen, do: StreamData.member_of(BandConfig.all_bands())
|
|
defp freq_gen, do: StreamData.member_of(BandConfig.all_freqs())
|
|
defp month_gen, do: StreamData.integer(1..12)
|
|
|
|
property "get/1 returns a well-formed struct for every supported frequency" do
|
|
check all(freq <- freq_gen()) do
|
|
band = BandConfig.get(freq)
|
|
assert is_map(band)
|
|
assert band.freq_mhz == freq
|
|
assert is_binary(band.label)
|
|
assert band.humidity_effect in [:beneficial, :harmful]
|
|
assert is_map(band.seasonal_base)
|
|
assert is_map(band.seasonal_adj)
|
|
end
|
|
end
|
|
|
|
property "every band's seasonal_base covers all 12 months with 0..100 integers" do
|
|
check all(band <- band_config_gen()) do
|
|
Enum.each(1..12, fn month ->
|
|
value = Map.fetch!(band.seasonal_base, month)
|
|
assert is_integer(value)
|
|
assert value in 0..100
|
|
end)
|
|
end
|
|
end
|
|
|
|
property "weights/1 returns a map whose values sum to ~1.0" do
|
|
check all(band <- band_config_gen()) do
|
|
weights = BandConfig.weights(band)
|
|
total = weights |> Map.values() |> Enum.sum()
|
|
assert_in_delta total, 1.0, 1.0e-3
|
|
end
|
|
end
|
|
|
|
property "weights/1 covers exactly the ten scoring factors" do
|
|
# The scorer reduces over a factors map with these keys; if a band
|
|
# ever dropped one, `Map.fetch!` in `composite_score/2` would crash.
|
|
expected = MapSet.new(~w(humidity time_of_day td_depression refractivity sky season wind rain pressure pwat)a)
|
|
|
|
check all(band <- band_config_gen()) do
|
|
keys = band |> BandConfig.weights() |> Map.keys() |> MapSet.new()
|
|
assert keys == expected
|
|
end
|
|
end
|
|
|
|
property "weights/1 accepts nil and maps without override, returning the global defaults" do
|
|
defaults = BandConfig.weights()
|
|
|
|
check all(band <- band_config_gen()) do
|
|
# A band without its own `:weights` falls through to defaults.
|
|
if Map.has_key?(band, :weights) do
|
|
assert is_map(BandConfig.weights(band))
|
|
else
|
|
assert BandConfig.weights(band) == defaults
|
|
end
|
|
end
|
|
|
|
assert BandConfig.weights(nil) == defaults
|
|
end
|
|
|
|
property "unknown frequencies return nil from get/1" do
|
|
known = MapSet.new(BandConfig.all_freqs())
|
|
|
|
check all(freq <- StreamData.integer(1..1_000_000), freq not in known) do
|
|
assert BandConfig.get(freq) == nil
|
|
end
|
|
end
|
|
|
|
property "sunrise_table returns 12 numeric entries in a plausible hour range" do
|
|
table = BandConfig.sunrise_table()
|
|
assert length(table) == 12
|
|
|
|
check all(idx <- StreamData.integer(0..11)) do
|
|
entry = Enum.at(table, idx)
|
|
assert is_number(entry)
|
|
assert entry >= 4.0 and entry <= 9.0
|
|
end
|
|
end
|
|
|
|
property "humidity_beneficial_thresholds are strictly increasing in the threshold value" do
|
|
thresholds = BandConfig.humidity_beneficial_thresholds()
|
|
cutoffs = Enum.map(thresholds, fn {cutoff, _score} -> cutoff end)
|
|
assert cutoffs == Enum.sort(cutoffs)
|
|
assert length(Enum.uniq(cutoffs)) == length(cutoffs)
|
|
end
|
|
|
|
property "refractivity_thresholds are strictly decreasing (more negative gradient first)" do
|
|
# `find_refractivity_threshold/3` walks the list and picks the
|
|
# first cutoff greater than the observed gradient — the ordering
|
|
# is what makes that correct.
|
|
cutoffs =
|
|
Enum.map(BandConfig.refractivity_thresholds(), fn {cutoff, _b, _h} -> cutoff end)
|
|
|
|
assert cutoffs == Enum.sort(cutoffs)
|
|
assert length(Enum.uniq(cutoffs)) == length(cutoffs)
|
|
end
|
|
|
|
property "tiers are ordered descending by min_score and cover 0..100" do
|
|
tiers = BandConfig.tiers()
|
|
min_scores = Enum.map(tiers, & &1.min_score)
|
|
assert min_scores == Enum.sort(min_scores, :desc)
|
|
assert List.last(min_scores) == 0
|
|
assert hd(min_scores) <= 100
|
|
|
|
check all(s <- StreamData.integer(0..100)) do
|
|
# Every score in 0..100 matches at least one tier.
|
|
assert Enum.any?(tiers, fn tier -> s >= tier.min_score end)
|
|
end
|
|
end
|
|
|
|
property "band_options pairs each band label with its stringified freq_mhz" do
|
|
options = BandConfig.band_options()
|
|
assert length(options) == length(BandConfig.all_bands())
|
|
|
|
check all({label, value} <- StreamData.member_of(options)) do
|
|
assert is_binary(label)
|
|
assert is_binary(value)
|
|
{freq, ""} = Integer.parse(value)
|
|
band = BandConfig.get(freq)
|
|
assert band.label == label
|
|
end
|
|
end
|
|
|
|
property "every month has a non-negative seasonal_adj value for every band" do
|
|
check all(band <- band_config_gen(), month <- month_gen()) do
|
|
# The adjustment map is sparse — nil for missing entries is fine,
|
|
# but any explicit entry must be a number.
|
|
case Map.get(band.seasonal_adj, month) do
|
|
nil -> :ok
|
|
value -> assert is_number(value)
|
|
end
|
|
end
|
|
end
|
|
end
|