test: add /qsos→/contacts redirect + scorer conversion properties

- PageController: cover the legacy `/qsos` → `/contacts` redirect pair
  (both the index and the id-preserving per-resource redirect), which
  had no coverage since the route rename.
- Scorer property tests:
  - f_to_c / c_to_f are inverses up to float epsilon, both propagate
    nil, and the C→F slope is exactly 5/9.
  - wind_speed_kts is non-negative, propagates nil on either axis,
    and matches sqrt(u²+v²)·1.94384 (the 5-digit kts factor the
    scorer uses).
  - precip_to_rate_mmhr is non-negative, 0 for nil/zero/negative.
  - dbz_to_rain_rate_mmhr is 0 below 5 dBZ (and for nil), and
    monotonically non-decreasing above — guards against accidental
    inversion of the Marshall-Palmer lookup.

Suite: 2,382 tests + 155 properties (was 2,380 + 148); coverage
71.08% → 71.12%; credo strict clean.
This commit is contained in:
Graham McIntire 2026-04-23 14:03:02 -05:00
parent b3c035793a
commit e7fb34eb6f
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 101 additions and 0 deletions

View file

@ -12,6 +12,92 @@ defmodule Microwaveprop.Propagation.ScorerPropertyTest do
alias Microwaveprop.Propagation.BandConfig
alias Microwaveprop.Propagation.Scorer
describe "temperature conversions" do
property "f_to_c and c_to_f are inverses up to float epsilon" do
check all(c <- float(min: -100.0, max: 150.0)) do
round_tripped = c |> Scorer.c_to_f() |> Scorer.f_to_c()
assert_in_delta round_tripped, c, 1.0e-9
end
end
property "f_to_c and c_to_f propagate nil (scorer nil-pass contract)" do
assert Scorer.f_to_c(nil) == nil
assert Scorer.c_to_f(nil) == nil
end
property "f_to_c is linear (a F increase of ΔF maps to (5/9)·ΔF C)" do
check all(
base <- float(min: -50.0, max: 120.0),
delta <- float(min: -30.0, max: 30.0)
) do
d_c = Scorer.f_to_c(base + delta) - Scorer.f_to_c(base)
assert_in_delta d_c, delta * 5.0 / 9.0, 1.0e-9
end
end
end
describe "wind_speed_kts/2" do
property "is non-negative and propagates nil" do
assert Scorer.wind_speed_kts(nil, 1.0) == nil
assert Scorer.wind_speed_kts(1.0, nil) == nil
check all(
u <- float(min: -30.0, max: 30.0),
v <- float(min: -30.0, max: 30.0)
) do
kts = Scorer.wind_speed_kts(u, v)
assert is_number(kts)
assert kts >= 0.0
end
end
property "magnitude equals sqrt(u²+v²) × m/s→kts conversion" do
# Scorer uses the 5-digit 1.94384 factor (not the full-precision
# 1.9438444924...). That's ~0.001% off, which tops out at a few
# millimetres per hour on a 60 kt wind — too small to matter for
# propagation scoring.
check all(
u <- float(min: -20.0, max: 20.0),
v <- float(min: -20.0, max: 20.0)
) do
speed_ms = :math.sqrt(u * u + v * v)
expected = speed_ms * 1.94384
assert_in_delta Scorer.wind_speed_kts(u, v), expected, 1.0e-6
end
end
end
describe "precip_to_rate_mmhr/1 and dbz_to_rain_rate_mmhr/1" do
property "precip_to_rate_mmhr is non-negative and 0-valued for nil/0" do
assert Scorer.precip_to_rate_mmhr(nil) == 0.0
assert Scorer.precip_to_rate_mmhr(0.0) == 0.0
assert Scorer.precip_to_rate_mmhr(-1.0) == 0.0
check all(mm <- float(min: 0.01, max: 200.0)) do
rate = Scorer.precip_to_rate_mmhr(mm)
assert rate >= 0.0
assert rate == mm
end
end
property "dbz_to_rain_rate_mmhr is 0 below 5 dBZ and monotonic non-decreasing above" do
check all(dbz <- float(min: -30.0, max: 4.99)) do
assert Scorer.dbz_to_rain_rate_mmhr(dbz) == 0.0
end
assert Scorer.dbz_to_rain_rate_mmhr(nil) == 0.0
check all(
a <- float(min: 5.0, max: 60.0),
delta <- float(min: 0.0, max: 30.0)
) do
b = a + delta
assert Scorer.dbz_to_rain_rate_mmhr(a) <= Scorer.dbz_to_rain_rate_mmhr(b) + 1.0e-9
end
end
end
# ── Generators ──────────────────────────────────────────────────
# All configured bands — harmful (24+ GHz) and beneficial (10 GHz and

View file

@ -6,6 +6,21 @@ defmodule MicrowavepropWeb.PageControllerTest do
assert redirected_to(conn) == "/map"
end
describe "legacy /qsos redirects" do
# The app was renamed `qsos` → `contacts` in the 2025 refactor; the
# old routes stay around so bookmarked links don't 404.
test "GET /qsos permanent-redirects to /contacts", %{conn: conn} do
conn = get(conn, "/qsos")
assert redirected_to(conn) == "/contacts"
end
test "GET /qsos/:id permanent-redirects to /contacts/:id preserving the id", %{conn: conn} do
id = "7d3cf5c8-6fd4-44b5-9b8e-84d9ef2c1b10"
conn = get(conn, "/qsos/#{id}")
assert redirected_to(conn) == "/contacts/#{id}"
end
end
# RFC 8288 Link headers for agent discovery. These advertise resources
# that actually exist in this app to crawlers/agents that look at
# response headers before parsing HTML.