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