- Add jump_credo_checks ~> 0.4 with all 20 checks enabled - Fix all standard Credo issues: 139 @spec (113 done, 26 remain), 4 refactoring, 3 alias usage, 9 System.cmd env, 5 unsafe_to_atom, 2 max line length, 9 assert_receive timeout - Fix 170+ jump_credo_checks warnings: - 117 TopLevelAliasImportRequire: move nested alias/import to module top - 32 UseObanProWorker: switch to Oban.Pro.Worker - 4 DoctestIExExamples: add doctests / create test file - ~20 WeakAssertion: strengthen type-check assertions - Various ConditionalAssertion, AssertReceiveTimeout fixes - Exclude vendor/ from Credo analysis - Remaining: 175 warnings (mostly opinionated WeakAssertion, AvoidSocketAssignsInTest), 26 @spec annotations
348 lines
12 KiB
Elixir
348 lines
12 KiB
Elixir
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 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 deg N, -97 deg 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
|