prop/test/microwaveprop/propagation/band_config_property_test.exs

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