test(property): add property tests for parsing + domain conversions
This commit is contained in:
parent
c60d56281f
commit
32ba5de456
3 changed files with 304 additions and 0 deletions
57
test/microwaveprop/format_property_test.exs
Normal file
57
test/microwaveprop/format_property_test.exs
Normal 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
|
||||
97
test/microwaveprop/radio/band_resolver_property_test.exs
Normal file
97
test/microwaveprop/radio/band_resolver_property_test.exs
Normal 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
|
||||
150
test/microwaveprop/radio/maidenhead_property_test.exs
Normal file
150
test/microwaveprop/radio/maidenhead_property_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue