From 391a162bcd2c12debd2fd6cc64700108467ed5c8 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 16 Apr 2026 14:01:25 -0500 Subject: [PATCH] test: cover beacons/beacon schema 46 characterization tests: changeset validation, Maidenhead derivation (both directions), bearing normalization, keying options, mW/frequency formatting helpers. --- test/microwaveprop/beacons/beacon_test.exs | 349 +++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 test/microwaveprop/beacons/beacon_test.exs diff --git a/test/microwaveprop/beacons/beacon_test.exs b/test/microwaveprop/beacons/beacon_test.exs new file mode 100644 index 00000000..4b6800e0 --- /dev/null +++ b/test/microwaveprop/beacons/beacon_test.exs @@ -0,0 +1,349 @@ +defmodule Microwaveprop.Beacons.BeaconTest do + use Microwaveprop.DataCase, async: true + + alias Ecto.Changeset + alias Microwaveprop.Beacons.Beacon + + @valid_attrs %{ + frequency_mhz: 10_368.1, + callsign: "W5HN", + lat: 32.95, + lon: -97.22, + power_mw: 500.0, + height_ft: 40, + keying: "on_off" + } + + describe "changeset/2 — happy path" do + test "valid attrs produce a valid changeset" do + changeset = Beacon.changeset(%Beacon{}, @valid_attrs) + assert changeset.valid? + end + + test "upcases and trims callsign" do + attrs = %{@valid_attrs | callsign: " w5hn "} + changeset = Beacon.changeset(%Beacon{}, attrs) + assert Changeset.get_field(changeset, :callsign) == "W5HN" + end + + test "rounds lat/lon to 6 decimals" do + attrs = %{@valid_attrs | lat: 32.123456789, lon: -97.987654321} + changeset = Beacon.changeset(%Beacon{}, attrs) + assert Changeset.get_field(changeset, :lat) == 32.123457 + assert Changeset.get_field(changeset, :lon) == -97.987654 + end + + test "integer height_ft is passed through unchanged" do + attrs = Map.put(@valid_attrs, :height_ft, 42) + changeset = Beacon.changeset(%Beacon{}, attrs) + assert Changeset.get_field(changeset, :height_ft) == 42 + end + end + + describe "changeset/2 — required fields" do + test "requires frequency_mhz, callsign, lat, lon, power_mw, height_ft, grid" do + changeset = Beacon.changeset(%Beacon{}, %{}) + errors = errors_on(changeset) + + assert errors[:frequency_mhz] + assert errors[:callsign] + assert errors[:lat] + assert errors[:lon] + assert errors[:power_mw] + assert errors[:height_ft] + # grid is validated by validate_grid_format even though not in @required_fields + assert "can't be blank" in errors[:grid] + # keying has a schema default ("on_off") so no required error fires + refute errors[:keying] + end + + test "explicitly-nil keying triggers required error" do + attrs = Map.put(@valid_attrs, :keying, nil) + changeset = Beacon.changeset(%Beacon{}, attrs) + assert errors_on(changeset)[:keying] + end + end + + describe "changeset/2 — numeric validations" do + test "frequency_mhz must be greater than 0" do + changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | frequency_mhz: 0}) + assert errors_on(changeset)[:frequency_mhz] + end + + test "power_mw must be >= 0" do + changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | power_mw: -1.0}) + assert errors_on(changeset)[:power_mw] + end + + test "power_mw accepts 0" do + changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | power_mw: 0.0}) + assert changeset.valid? + end + + test "height_ft must be >= 0" do + changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | height_ft: -5}) + assert errors_on(changeset)[:height_ft] + end + + test "lat must be within [-90, 90]" do + too_high = Beacon.changeset(%Beacon{}, %{@valid_attrs | lat: 91.0}) + too_low = Beacon.changeset(%Beacon{}, %{@valid_attrs | lat: -91.0}) + assert errors_on(too_high)[:lat] + assert errors_on(too_low)[:lat] + end + + test "lon must be within [-180, 180]" do + too_high = Beacon.changeset(%Beacon{}, %{@valid_attrs | lon: 181.0}) + too_low = Beacon.changeset(%Beacon{}, %{@valid_attrs | lon: -181.0}) + assert errors_on(too_high)[:lon] + assert errors_on(too_low)[:lon] + end + + test "callsign length must be 3..10 chars" do + too_short = Beacon.changeset(%Beacon{}, %{@valid_attrs | callsign: "W5"}) + too_long = Beacon.changeset(%Beacon{}, %{@valid_attrs | callsign: "W5HNABCDEFG"}) + assert errors_on(too_short)[:callsign] + assert errors_on(too_long)[:callsign] + end + + test "beamwidth_deg must be greater than 0 and <= 360" do + zero = Beacon.changeset(%Beacon{}, Map.put(@valid_attrs, :beamwidth_deg, 0.0)) + too_big = Beacon.changeset(%Beacon{}, Map.put(@valid_attrs, :beamwidth_deg, 361.0)) + assert errors_on(zero)[:beamwidth_deg] + assert errors_on(too_big)[:beamwidth_deg] + end + + test "beamwidth_deg accepts 360 and small positive values" do + ok_high = Beacon.changeset(%Beacon{}, Map.put(@valid_attrs, :beamwidth_deg, 360.0)) + ok_low = Beacon.changeset(%Beacon{}, Map.put(@valid_attrs, :beamwidth_deg, 10.0)) + assert ok_high.valid? + assert ok_low.valid? + end + end + + describe "changeset/2 — keying validation" do + test "rejects unknown keying" do + changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | keying: "not_a_mode"}) + assert errors_on(changeset)[:keying] + end + + test "accepts every documented keying" do + for key <- Beacon.keyings() do + changeset = Beacon.changeset(%Beacon{}, %{@valid_attrs | keying: key}) + assert changeset.valid?, "Expected keying #{key} to be valid" + end + end + end + + describe "changeset/2 — Maidenhead grid derivation" do + test "derives grid from lat/lon when grid is blank" do + changeset = Beacon.changeset(%Beacon{}, @valid_attrs) + grid = Changeset.get_field(changeset, :grid) + assert is_binary(grid) + assert String.length(grid) == 6 + assert String.starts_with?(grid, "EM") + end + + test "derives lat/lon from grid when lat/lon are blank" do + attrs = + @valid_attrs + |> Map.drop([:lat, :lon]) + |> Map.put(:grid, "EM12kp") + + changeset = Beacon.changeset(%Beacon{}, attrs) + lat = Changeset.get_field(changeset, :lat) + lon = Changeset.get_field(changeset, :lon) + assert is_float(lat) + assert is_float(lon) + # EM12 is centered around ~32.5°N, -97°W + assert lat >= 32.0 and lat <= 33.0 + assert lon >= -98.0 and lon <= -97.0 + end + + test "normalizes grid to standard case (field uppercase, subsquare lowercase)" do + attrs = Map.put(@valid_attrs, :grid, "em12KP") + changeset = Beacon.changeset(%Beacon{}, attrs) + assert Changeset.get_field(changeset, :grid) == "EM12kp" + end + + test "rejects invalid Maidenhead grid" do + attrs = Map.put(@valid_attrs, :grid, "ZZ99") + changeset = Beacon.changeset(%Beacon{}, attrs) + assert "is not a valid Maidenhead grid" in errors_on(changeset)[:grid] + end + + test "rejects odd-length grid" do + attrs = Map.put(@valid_attrs, :grid, "EM1") + changeset = Beacon.changeset(%Beacon{}, attrs) + assert errors_on(changeset)[:grid] + end + + test "accepts 4-char grid" do + attrs = + @valid_attrs + |> Map.drop([:lat, :lon]) + |> Map.put(:grid, "EM12") + + changeset = Beacon.changeset(%Beacon{}, attrs) + assert changeset.valid? + assert Changeset.get_field(changeset, :grid) == "EM12" + end + end + + describe "changeset/2 — bearing normalization and validation" do + test "blank bearing becomes \"omni\"" do + for blank <- ["", " "] do + attrs = Map.put(@valid_attrs, :bearing, blank) + changeset = Beacon.changeset(%Beacon{}, attrs) + assert Changeset.get_field(changeset, :bearing) == "omni" + assert changeset.valid?, "Expected blank bearing #{inspect(blank)} to validate" + end + end + + test "case-insensitive omni is normalized" do + for form <- ["OMNI", "Omni", " omni ", " OMNI "] do + attrs = Map.put(@valid_attrs, :bearing, form) + changeset = Beacon.changeset(%Beacon{}, attrs) + assert Changeset.get_field(changeset, :bearing) == "omni" + end + end + + test "numeric string 0 accepted" do + attrs = Map.put(@valid_attrs, :bearing, "0") + changeset = Beacon.changeset(%Beacon{}, attrs) + assert changeset.valid? + assert Changeset.get_field(changeset, :bearing) == "0" + end + + test "numeric string 360 accepted" do + attrs = Map.put(@valid_attrs, :bearing, "360") + changeset = Beacon.changeset(%Beacon{}, attrs) + assert changeset.valid? + assert Changeset.get_field(changeset, :bearing) == "360" + end + + test "numeric string within range accepted" do + attrs = Map.put(@valid_attrs, :bearing, "270.5") + changeset = Beacon.changeset(%Beacon{}, attrs) + assert changeset.valid? + end + + test "negative bearing rejected (not normalized)" do + attrs = Map.put(@valid_attrs, :bearing, "-10") + changeset = Beacon.changeset(%Beacon{}, attrs) + refute changeset.valid? + assert errors_on(changeset)[:bearing] + end + + test "bearing greater than 360 rejected (not normalized)" do + attrs = Map.put(@valid_attrs, :bearing, "361") + changeset = Beacon.changeset(%Beacon{}, attrs) + refute changeset.valid? + assert errors_on(changeset)[:bearing] + end + + test "non-numeric, non-omni bearing rejected" do + attrs = Map.put(@valid_attrs, :bearing, "north") + changeset = Beacon.changeset(%Beacon{}, attrs) + refute changeset.valid? + assert errors_on(changeset)[:bearing] + end + + test "numeric with trailing text rejected" do + attrs = Map.put(@valid_attrs, :bearing, "180deg") + changeset = Beacon.changeset(%Beacon{}, attrs) + refute changeset.valid? + assert errors_on(changeset)[:bearing] + end + end + + describe "keyings/0 and keying_label/1" do + test "keyings/0 lists all documented modes" do + keyings = Beacon.keyings() + assert "on_off" in keyings + assert "fsk" in keyings + assert "wspr" in keyings + assert "q65a_15" in keyings + assert "q65e_120" in keyings + # 2 simple + 2 voice/digital + 5 groups * 4 periods = 24 + assert length(keyings) == 24 + end + + test "keying_label/1 returns human label for known key" do + assert Beacon.keying_label("on_off") == "On/Off" + assert Beacon.keying_label("wspr") == "WSPR" + assert Beacon.keying_label("q65a_15") == "Q65A-15" + end + + test "keying_label/1 returns the input for unknown key" do + assert Beacon.keying_label("mystery") == "mystery" + end + end + + describe "keying_options/0" do + test "returns a grouped list suitable for options_for_select/2" do + options = Beacon.keying_options() + groups = Enum.map(options, fn {g, _} -> g end) + + assert "Simple" in groups + assert "Voice / Digital" in groups + assert "Q65A" in groups + assert "Q65E" in groups + end + + test "Q65A group contains four period options in {label, value} form" do + {_group, options} = Enum.find(Beacon.keying_options(), fn {g, _} -> g == "Q65A" end) + assert length(options) == 4 + assert {"Q65A-15", "q65a_15"} in options + assert {"Q65A-120", "q65a_120"} in options + end + end + + describe "format_mw/1" do + test "nil returns empty string" do + assert Beacon.format_mw(nil) == "" + end + + test "integer is rendered with commas" do + assert Beacon.format_mw(500) == "500" + assert Beacon.format_mw(1_500) == "1,500" + assert Beacon.format_mw(1_000_000) == "1,000,000" + end + + test "whole-valued float is rendered without decimal" do + assert Beacon.format_mw(500.0) == "500" + assert Beacon.format_mw(24_192.0) == "24,192" + end + + test "fractional float keeps up to 3 decimals and trims trailing zeros" do + assert Beacon.format_mw(1.5) == "1.5" + assert Beacon.format_mw(1.25) == "1.25" + assert Beacon.format_mw(1.125) == "1.125" + assert Beacon.format_mw(1_500.5) == "1,500.5" + end + + test "non-number falls back to to_string/1" do + assert Beacon.format_mw("abc") == "abc" + end + end + + describe "format_freq/1" do + test "nil returns empty string" do + assert Beacon.format_freq(nil) == "" + end + + test "integer MHz renders with comma separators" do + assert Beacon.format_freq(10_368) == "10,368" + assert Beacon.format_freq(144) == "144" + end + + test "float MHz trims trailing zeros" do + assert Beacon.format_freq(10_368.0) == "10,368" + assert Beacon.format_freq(10_368.1) == "10,368.1" + end + + test "non-number falls back to to_string/1" do + assert Beacon.format_freq("abc") == "abc" + end + end +end