test(property): add property tests for parsing + domain conversions

This commit is contained in:
Graham McIntire 2026-04-21 13:57:47 -05:00
parent c60d56281f
commit 32ba5de456
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
3 changed files with 304 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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), &<<?A + &1>>)
# 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), &<<?a + &1>>)
defp digit_char, do: map(integer(0..9), &<<?0 + &1>>)
# 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