From e7fb34eb6fcef6fe70191dad1757d7f0bafa1da2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 23 Apr 2026 14:03:02 -0500 Subject: [PATCH] =?UTF-8?q?test:=20add=20/qsos=E2=86=92/contacts=20redirec?= =?UTF-8?q?t=20+=20scorer=20conversion=20properties?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- .../propagation/scorer_property_test.exs | 86 +++++++++++++++++++ .../controllers/page_controller_test.exs | 15 ++++ 2 files changed, 101 insertions(+) diff --git a/test/microwaveprop/propagation/scorer_property_test.exs b/test/microwaveprop/propagation/scorer_property_test.exs index 3241832a..d24f6c5c 100644 --- a/test/microwaveprop/propagation/scorer_property_test.exs +++ b/test/microwaveprop/propagation/scorer_property_test.exs @@ -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 diff --git a/test/microwaveprop_web/controllers/page_controller_test.exs b/test/microwaveprop_web/controllers/page_controller_test.exs index 103a351e..0c12c190 100644 --- a/test/microwaveprop_web/controllers/page_controller_test.exs +++ b/test/microwaveprop_web/controllers/page_controller_test.exs @@ -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.