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.
451 lines
14 KiB
Elixir
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
|