prop/test/microwaveprop/beacons_test.exs
Graham McIntire fc245367e3
User profiles at /u/:callsign, flexible band input, assorted UX cleanup
Profile page:
  * New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
    page showing a user's contacts and beacons. Resolves case-
    insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
    callsigns redirect to /. Uses daisyUI card / stats / table
    components with an avatar-placeholder initial and hero icons.
  * Accounts.get_user_by_callsign/1 (case-insensitive) plus
    Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
    back the page. Beacons list includes both approved and pending so
    owners see their drafts.
  * The top nav bar and the three LiveView sidebars (MapLive,
    WeatherMapLive, ContactMapLive) now render the logged-in callsign
    as a navigate link to /u/:callsign instead of a static label.
  * Nine new tests cover the lookup, the LiveView render, and the
    ownership-scoped queries.

Flexible band input:
  * New Microwaveprop.Radio.BandResolver module converts any of:
    ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
    insensitive), numeric frequency strings ("903.100", "10368.000"),
    and canonical MHz integers into the one of the site's known bands.
    Returns the nearest allowed band for numeric inputs >= 900 MHz,
    nil otherwise.
  * 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
    AdifImport.@allowed_bands, and the BandResolver list so "33cm"
    round-trips end-to-end.
  * AdifImport and CsvImport now delegate band resolution to
    BandResolver, and Radio.create_contact/2 normalizes the :band attr
    on the way in so the manual form and any API callers benefit too.
    CsvImport's "invalid band" tests previously used 99999 MHz which
    the new resolver snaps to the nearest allowed band; swapped to
    "notaband" which is truly unresolvable.

Contacts and beacons list UX:
  * Remove the "Submitted" column from /contacts — it duplicated info
    already visible on the detail page and was pushing the real
    columns off narrow viewports. submitted_cell/1 and its three
    column-specific tests go with it.
  * Hide the Lat / Lon columns from /beacons — six decimal places of
    coordinates weren't useful next to the grid square and took a
    disproportionate amount of row width.
2026-04-12 16:13:08 -05:00

451 lines
14 KiB
Elixir

defmodule Microwaveprop.BeaconsTest do
use Microwaveprop.DataCase
use ExUnitProperties
import Microwaveprop.AccountsFixtures
import Microwaveprop.BeaconsFixtures
alias Microwaveprop.Beacons
alias Microwaveprop.Beacons.Beacon
@invalid_attrs %{
frequency_mhz: nil,
callsign: nil,
grid: nil,
lat: nil,
lon: nil,
power_mw: nil,
height_ft: nil
}
describe "Beacon.format_freq/1" do
test "renders integer frequencies with comma separators" do
assert Beacon.format_freq(10_368) == "10,368"
end
test "renders whole-number floats cleanly (regression for 24192.0 crash)" do
# 24192.0 used to crash format_freq because float rounding made
# `frac == 0.0` sometimes false, and trim_trailing_zeros then stripped
# the decimal point, leaving a single-element split result that
# couldn't be destructured as `[_int, frac_part]`.
assert Beacon.format_freq(24_192.0) == "24,192"
assert Beacon.format_freq(10_368.0) == "10,368"
end
test "renders fractional frequencies with trimmed trailing zeros" do
assert Beacon.format_freq(10_368.1) == "10,368.1"
assert Beacon.format_freq(10_368.123) == "10,368.123"
end
test "handles nil" do
assert Beacon.format_freq(nil) == ""
end
end
describe "Beacon.format_mw/1" do
test "handles whole-number floats cleanly (regression for the 24192 crash)" do
assert Beacon.format_mw(100.0) == "100"
assert Beacon.format_mw(1000.0) == "1,000"
end
test "renders integer power with commas" do
assert Beacon.format_mw(5000) == "5,000"
end
test "renders fractional power with trimmed trailing zeros" do
assert Beacon.format_mw(12.5) == "12.5"
assert Beacon.format_mw(0.125) == "0.125"
end
test "handles nil" do
assert Beacon.format_mw(nil) == ""
end
end
describe "Beacon.format_freq/1 property tests" do
# Ranges cover the realistic microwave band set (902 MHz to 241 GHz)
# plus some float edge cases that trigger rounding quirks.
property "never crashes on any integer in the microwave range" do
check all(n <- integer(1..300_000)) do
result = Beacon.format_freq(n)
assert is_binary(result)
assert result != ""
end
end
property "never crashes on any float in the microwave range" do
check all(n <- float(min: 1.0, max: 300_000.0)) do
result = Beacon.format_freq(n)
assert is_binary(result)
assert result != ""
end
end
property "never crashes on any float including whole numbers and edge floats" do
check all(
n <-
one_of([
float(min: 0.0, max: 300_000.0),
map(integer(1..300_000), &(&1 * 1.0)),
constant(24_192.0),
constant(10_368.0),
constant(47_088.0)
])
) do
assert is_binary(Beacon.format_freq(n))
end
end
property "format_mw never crashes on any number" do
check all(
n <-
one_of([
integer(0..1_000_000),
float(min: 0.0, max: 1_000_000.0),
map(integer(0..1000), &(&1 * 1.0))
])
) do
assert is_binary(Beacon.format_mw(n))
end
end
end
describe "list_beacons/0" do
test "returns only approved beacons" do
u1 = user_fixture()
u2 = user_fixture()
{:ok, b1} = Beacons.create_beacon(u1, valid_beacon_attrs())
{:ok, b1} = Beacons.approve_beacon(b1)
_pending = beacon_fixture(u2, callsign: "W5TX")
assert [^b1] = Beacons.list_beacons()
end
end
describe "list_pending_beacons/0" do
test "returns only unapproved beacons" do
u1 = user_fixture()
u2 = user_fixture()
{:ok, approved} = Beacons.create_beacon(u1, valid_beacon_attrs())
{:ok, _approved} = Beacons.approve_beacon(approved)
pending = beacon_fixture(u2, callsign: "W5TX")
assert [^pending] = Beacons.list_pending_beacons()
end
end
describe "list_beacons_for_user/1" do
test "returns both approved and pending beacons owned by the user" do
user = user_fixture()
other = user_fixture()
{:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs())
{:ok, approved} = Beacons.approve_beacon(approved)
pending = beacon_fixture(user, callsign: "W5PND")
_theirs = beacon_fixture(other, callsign: "W5THR")
ids = user |> Beacons.list_beacons_for_user() |> Enum.map(& &1.id) |> Enum.sort()
assert ids == Enum.sort([approved.id, pending.id])
end
test "returns an empty list when the user owns nothing" do
user = user_fixture()
assert Beacons.list_beacons_for_user(user) == []
end
end
describe "approve_beacon/1" do
test "marks the beacon as approved and broadcasts an update" do
user = user_fixture()
beacon = beacon_fixture(user)
refute beacon.approved
Beacons.subscribe_beacons()
assert {:ok, approved} = Beacons.approve_beacon(beacon)
assert approved.approved == true
assert_receive {:updated, %Beacon{approved: true}}
end
end
describe "get_beacon!/1" do
test "returns the beacon with given id" do
user = user_fixture()
beacon = beacon_fixture(user)
assert Beacons.get_beacon!(beacon.id).id == beacon.id
end
end
describe "create_beacon/2" do
test "with valid data creates a beacon and records the creator" do
user = user_fixture()
attrs = valid_beacon_attrs()
assert {:ok, %Beacon{} = beacon} = Beacons.create_beacon(user, attrs)
assert beacon.frequency_mhz == attrs.frequency_mhz
assert beacon.callsign == "W5HN"
assert beacon.lat == attrs.lat
assert beacon.lon == attrs.lon
assert beacon.power_mw == attrs.power_mw
assert beacon.height_ft == attrs.height_ft
assert beacon.user_id == user.id
end
test "anonymous submission creates a beacon with no user_id, unapproved" do
attrs = valid_beacon_attrs()
assert {:ok, %Beacon{} = beacon} = Beacons.create_beacon(nil, attrs)
assert beacon.user_id == nil
assert beacon.approved == false
end
test "new beacons default to unapproved" do
user = user_fixture()
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.approved == false
end
test "ignores client-supplied approved value" do
user = user_fixture()
attrs = Map.put(valid_beacon_attrs(), :approved, true)
assert {:ok, beacon} = Beacons.create_beacon(user, attrs)
assert beacon.approved == false
end
test "derives grid from lat/lon when grid is omitted" do
user = user_fixture()
# DFW is EM12
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(lat: 32.897, lon: -97.038))
assert String.starts_with?(beacon.grid, "EM12")
end
test "derives lat/lon from grid when lat/lon are omitted" do
user = user_fixture()
attrs =
[grid: "EM12kp"]
|> valid_beacon_attrs()
|> Map.drop([:lat, :lon])
assert {:ok, beacon} = Beacons.create_beacon(user, attrs)
assert beacon.grid == "EM12kp"
assert_in_delta beacon.lat, 32.6458, 0.01
assert_in_delta beacon.lon, -97.125, 0.01
end
test "keeps an explicitly provided valid grid" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(grid: "EM13"))
assert beacon.grid == "EM13"
end
test "rejects an invalid grid" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(grid: "ZZ99"))
assert "is not a valid Maidenhead grid" in errors_on(changeset).grid
end
test "defaults on_the_air to true" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.on_the_air == true
end
test "accepts on_the_air: false" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(on_the_air: false))
assert beacon.on_the_air == false
end
test "defaults keying to on_off" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.keying == "on_off"
end
test "accepts keying: fsk" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: "fsk"))
assert beacon.keying == "fsk"
end
test "rejects an unknown keying" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(keying: "cw"))
assert "is invalid" in errors_on(changeset).keying
end
test "accepts fm_voice keying" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: "fm_voice"))
assert beacon.keying == "fm_voice"
end
test "accepts wspr keying" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: "wspr"))
assert beacon.keying == "wspr"
end
test "accepts all Q65 keying variants" do
user = user_fixture()
variants =
for letter <- ~w(a b c d e), period <- ~w(15 30 60 120), do: "q65#{letter}_#{period}"
for keying <- variants do
assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(keying: keying))
assert beacon.keying == keying
end
end
test "defaults bearing to omni" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs())
assert beacon.bearing == "omni"
end
test "normalizes empty bearing to omni" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: ""))
assert beacon.bearing == "omni"
end
test "normalizes 'Omni' to 'omni'" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "Omni"))
assert beacon.bearing == "omni"
end
test "accepts a numeric bearing" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "90"))
assert beacon.bearing == "90"
end
test "accepts bearing of 0 and 360" do
user = user_fixture()
{:ok, b0} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "0"))
assert b0.bearing == "0"
{:ok, b360} = Beacons.create_beacon(user, valid_beacon_attrs(bearing: "360"))
assert b360.bearing == "360"
end
test "rejects a non-numeric non-omni bearing" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "north"))
assert errors_on(changeset).bearing != []
end
test "rejects a bearing greater than 360" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "400"))
assert errors_on(changeset).bearing != []
end
test "rejects a negative bearing" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(bearing: "-10"))
assert errors_on(changeset).bearing != []
end
test "stores beamwidth_deg" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 30.0))
assert beacon.beamwidth_deg == 30.0
end
test "rejects a non-positive beamwidth" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 0))
assert errors_on(changeset).beamwidth_deg != []
end
test "rejects beamwidth > 360" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(beamwidth_deg: 400))
assert errors_on(changeset).beamwidth_deg != []
end
test "stores notes" do
user = user_fixture()
notes = "Antenna 20 ft AGL, horizontal polarization"
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(notes: notes))
assert beacon.notes == notes
end
test "upcases the callsign" do
user = user_fixture()
{:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs(callsign: "w5hn"))
assert beacon.callsign == "W5HN"
end
test "rejects non-positive frequency" do
user = user_fixture()
assert {:error, changeset} =
Beacons.create_beacon(user, valid_beacon_attrs(frequency_mhz: 0))
assert "must be greater than 0" in errors_on(changeset).frequency_mhz
end
test "with invalid data returns error changeset" do
user = user_fixture()
assert {:error, %Ecto.Changeset{}} = Beacons.create_beacon(user, @invalid_attrs)
end
end
describe "update_beacon/2" do
test "with valid data updates the beacon" do
user = user_fixture()
beacon = beacon_fixture(user)
update_attrs = %{frequency_mhz: 24_192.1, power_mw: 5.0}
assert {:ok, %Beacon{} = beacon} = Beacons.update_beacon(beacon, update_attrs)
assert beacon.frequency_mhz == 24_192.1
assert beacon.power_mw == 5.0
end
test "with invalid data returns error changeset" do
user = user_fixture()
beacon = beacon_fixture(user)
assert {:error, %Ecto.Changeset{}} = Beacons.update_beacon(beacon, @invalid_attrs)
assert Beacons.get_beacon!(beacon.id).callsign == beacon.callsign
end
end
describe "delete_beacon/1" do
test "deletes the beacon" do
user = user_fixture()
beacon = beacon_fixture(user)
assert {:ok, %Beacon{}} = Beacons.delete_beacon(beacon)
assert_raise Ecto.NoResultsError, fn -> Beacons.get_beacon!(beacon.id) end
end
end
describe "change_beacon/1" do
test "returns a beacon changeset" do
user = user_fixture()
beacon = beacon_fixture(user)
assert %Ecto.Changeset{} = Beacons.change_beacon(beacon)
end
end
end