From 32ba5de4564053014180280c758a3ff7e030bf0e Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 21 Apr 2026 13:57:47 -0500 Subject: [PATCH] test(property): add property tests for parsing + domain conversions --- test/microwaveprop/format_property_test.exs | 57 +++++++ .../radio/band_resolver_property_test.exs | 97 +++++++++++ .../radio/maidenhead_property_test.exs | 150 ++++++++++++++++++ 3 files changed, 304 insertions(+) create mode 100644 test/microwaveprop/format_property_test.exs create mode 100644 test/microwaveprop/radio/band_resolver_property_test.exs create mode 100644 test/microwaveprop/radio/maidenhead_property_test.exs diff --git a/test/microwaveprop/format_property_test.exs b/test/microwaveprop/format_property_test.exs new file mode 100644 index 00000000..6b270975 --- /dev/null +++ b/test/microwaveprop/format_property_test.exs @@ -0,0 +1,57 @@ +defmodule Microwaveprop.FormatPropertyTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Microwaveprop.Format + + # Any finite non-negative number is legitimate input for distance_km/1. + # StreamData floats default to the full representable range — we keep + # that but reject negatives since negative distances are meaningless + # (the algorithm callers always pass an already-absolute km value). + defp non_negative_number do + one_of([ + integer(0..1_000_000_000), + map(float(min: 0.0), fn f -> f end), + map(integer(0..1_000_000), &(&1 * 1.0)) + ]) + end + + describe "distance_km/1" do + property "returns a binary for nil" do + # Not really a property, just pinned for discoverability alongside + # the numeric properties. + assert Format.distance_km(nil) == "—" + end + + property "always returns a non-empty binary for any non-negative number" do + check all(n <- non_negative_number()) do + result = Format.distance_km(n) + assert is_binary(result) + assert result != "" + end + end + + property "output contains both `mi` and `km` units" do + check all(n <- non_negative_number()) do + result = Format.distance_km(n) + assert result =~ "mi" + assert result =~ "km" + end + end + + property "accepts Decimal input without crashing" do + check all(n <- integer(0..10_000)) do + decimal = Decimal.new(n) + assert is_binary(Format.distance_km(decimal)) + end + end + + property "accepts negative distances without crashing" do + # Not a valid input at the call site, but we don't want arbitrary + # numeric data (e.g. a sign slip in a caller) to crash the view. + check all(n <- float(min: -1.0e6, max: -0.01)) do + assert is_binary(Format.distance_km(n)) + end + end + end +end diff --git a/test/microwaveprop/radio/band_resolver_property_test.exs b/test/microwaveprop/radio/band_resolver_property_test.exs new file mode 100644 index 00000000..14153952 --- /dev/null +++ b/test/microwaveprop/radio/band_resolver_property_test.exs @@ -0,0 +1,97 @@ +defmodule Microwaveprop.Radio.BandResolverPropertyTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Microwaveprop.Radio.BandResolver + + describe "resolve/1 output contract" do + property "result is always nil or a member of allowed_bands/0 (strings)" do + check all(s <- string(:printable)) do + case BandResolver.resolve(s) do + nil -> :ok + mhz -> assert mhz in BandResolver.allowed_bands() + end + end + end + + property "result is always nil or a member of allowed_bands/0 (integers)" do + check all(i <- integer(-1_000..500_000)) do + case BandResolver.resolve(i) do + nil -> :ok + mhz -> assert mhz in BandResolver.allowed_bands() + end + end + end + + property "result is always nil or a member of allowed_bands/0 (floats)" do + check all(f <- float(min: -10.0, max: 500_000.0)) do + case BandResolver.resolve(f) do + nil -> :ok + mhz -> assert mhz in BandResolver.allowed_bands() + end + end + end + + property "never crashes on arbitrary term input" do + check all( + term <- + one_of([ + constant(nil), + constant(""), + constant(:atom), + constant([]), + constant(%{}), + string(:printable), + integer(), + float() + ]) + ) do + result = BandResolver.resolve(term) + assert is_nil(result) or is_integer(result) + end + end + end + + describe "resolve/1 is idempotent for canonical bands" do + property "every allowed band resolves to itself" do + check all(mhz <- member_of(BandResolver.allowed_bands())) do + assert BandResolver.resolve(mhz) == mhz + assert BandResolver.resolve(Integer.to_string(mhz)) == mhz + end + end + + property "resolve_as_string/1 round-trips through Integer.to_string" do + check all(mhz <- member_of(BandResolver.allowed_bands())) do + assert BandResolver.resolve_as_string(mhz) == Integer.to_string(mhz) + end + end + end + + describe "resolve/1 snap-to-nearest" do + property "frequencies within a small neighborhood of a microwave band resolve to that band" do + # The >= 900 MHz branch uses nearest-neighbor snapping. A small + # perturbation of a canonical band value must land on the same + # band — but we clamp the perturbed input to stay ≥ 900 so we + # don't cross the resolver's lower cutoff for 902 (the smallest + # microwave band). + bands_900_plus = Enum.filter(BandResolver.allowed_bands(), &(&1 >= 900)) + + check all( + mhz <- member_of(bands_900_plus), + delta_pct <- float(min: -0.001, max: 0.001) + ) do + perturbed = max(900.01, mhz * (1.0 + delta_pct)) + # Only assert when the perturbed value is still unambiguously + # closer to `mhz` than to any other allowed band. + nearest_other = + bands_900_plus + |> Enum.reject(&(&1 == mhz)) + |> Enum.min_by(&abs(&1 - perturbed)) + + if abs(perturbed - mhz) < abs(perturbed - nearest_other) do + assert BandResolver.resolve(perturbed) == mhz + end + end + end + end +end diff --git a/test/microwaveprop/radio/maidenhead_property_test.exs b/test/microwaveprop/radio/maidenhead_property_test.exs new file mode 100644 index 00000000..a248753b --- /dev/null +++ b/test/microwaveprop/radio/maidenhead_property_test.exs @@ -0,0 +1,150 @@ +defmodule Microwaveprop.Radio.MaidenheadPropertyTest do + use ExUnit.Case, async: true + use ExUnitProperties + + alias Microwaveprop.Radio.Maidenhead + + # Generators --------------------------------------------------------- + + # Upper-case field chars A-R (18 divisions, the only canonical output + # from from_latlon/3). + defp field_char, do: map(integer(0..17), &<>) + + # Upper-case subsquare chars A-X (24 divisions). from_latlon/3 emits + # lower-case, so we hold grids canonical in lower-case when assembling. + defp subsquare_char, do: map(integer(0..23), &<>) + + defp digit_char, do: map(integer(0..9), &<>) + + # Canonical 4-char grid: UPPER field + digits. to_latlon upcases, so + # staying canonical here gives us exact round-trip equality. + defp grid4 do + gen all( + f1 <- field_char(), + f2 <- field_char(), + d1 <- digit_char(), + d2 <- digit_char() + ) do + f1 <> f2 <> d1 <> d2 + end + end + + defp grid6 do + gen all( + g <- grid4(), + s1 <- subsquare_char(), + s2 <- subsquare_char() + ) do + g <> s1 <> s2 + end + end + + defp grid8 do + gen all( + g <- grid6(), + d1 <- digit_char(), + d2 <- digit_char() + ) do + g <> d1 <> d2 + end + end + + # Lat/lon that stay inside the representable field range. The top row + # (lat = 90.0) and the wrap-around longitude (180.0) sit exactly on a + # field boundary and land outside the A-R field set, so we stop just + # short. + defp latlon do + gen all( + lat <- float(min: -90.0, max: 89.999), + lon <- float(min: -180.0, max: 179.999) + ) do + {lat, lon} + end + end + + # Properties --------------------------------------------------------- + + describe "round-trip from_latlon/3 → to_latlon/1" do + property "precision 4: decoded center is within one grid cell of input" do + # A 4-char grid covers 2° lon × 1° lat; the center must be within + # half that distance of any point in the cell. + check all({lat, lon} <- latlon()) do + grid = Maidenhead.from_latlon(lat, lon, 4) + assert {:ok, {dlat, dlon}} = Maidenhead.to_latlon(grid) + assert abs(dlat - lat) <= 0.5 + 1.0e-9 + assert abs(dlon - lon) <= 1.0 + 1.0e-9 + end + end + + property "precision 6: decoded center is within half a subsquare" do + # 6-char subsquare is 5'×2.5' → 5/60 lon, 2.5/60 lat. + check all({lat, lon} <- latlon()) do + grid = Maidenhead.from_latlon(lat, lon, 6) + assert {:ok, {dlat, dlon}} = Maidenhead.to_latlon(grid) + assert abs(dlat - lat) <= 2.5 / 60 / 2 + 1.0e-9 + assert abs(dlon - lon) <= 5.0 / 60 / 2 + 1.0e-9 + end + end + + property "precision 8: decoded center is within half an extended square" do + check all({lat, lon} <- latlon()) do + grid = Maidenhead.from_latlon(lat, lon, 8) + assert {:ok, {dlat, dlon}} = Maidenhead.to_latlon(grid) + assert abs(dlat - lat) <= 2.5 / 60 / 10 / 2 + 1.0e-9 + assert abs(dlon - lon) <= 5.0 / 60 / 10 / 2 + 1.0e-9 + end + end + end + + describe "round-trip to_latlon/1 → from_latlon/3" do + property "precision 4 grids come back unchanged (after upcasing)" do + check all(grid <- grid4()) do + assert {:ok, {lat, lon}} = Maidenhead.to_latlon(grid) + assert Maidenhead.from_latlon(lat, lon, 4) == String.upcase(grid) + end + end + + property "precision 6 grids come back unchanged" do + # from_latlon/3 emits upper-case field/square and lower-case + # subsquare, matching the canonical form our generator produces. + check all(grid <- grid6()) do + assert {:ok, {lat, lon}} = Maidenhead.to_latlon(grid) + assert Maidenhead.from_latlon(lat, lon, 6) == grid + end + end + + property "precision 8 grids come back unchanged" do + check all(grid <- grid8()) do + assert {:ok, {lat, lon}} = Maidenhead.to_latlon(grid) + assert Maidenhead.from_latlon(lat, lon, 8) == grid + end + end + end + + describe "decoded bounds" do + property "to_latlon output stays inside valid geographic range" do + check all(grid <- grid8()) do + assert {:ok, {lat, lon}} = Maidenhead.to_latlon(grid) + assert lat >= -90.0 and lat <= 90.0 + assert lon >= -180.0 and lon <= 180.0 + end + end + end + + describe "valid?/1" do + property "accepts every grid emitted by from_latlon/3" do + check all( + {lat, lon} <- latlon(), + precision <- one_of([constant(4), constant(6), constant(8)]) + ) do + assert Maidenhead.valid?(Maidenhead.from_latlon(lat, lon, precision)) + end + end + + property "never crashes on arbitrary binary input" do + check all(s <- string(:printable)) do + assert is_boolean(Maidenhead.valid?(s)) + end + end + end +end