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/2" do test "returns both approved and pending beacons when the viewer is the owner" 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(user) |> Enum.map(& &1.id) |> Enum.sort() assert ids == Enum.sort([approved.id, pending.id]) end test "hides pending beacons from anonymous viewers" do user = user_fixture() {:ok, approved} = Beacons.create_beacon(user, valid_beacon_attrs()) {:ok, approved} = Beacons.approve_beacon(approved) _pending = beacon_fixture(user, callsign: "W5PND") assert [found] = Beacons.list_beacons_for_user(user) assert found.id == approved.id end test "hides pending beacons from other authenticated viewers" do user = user_fixture() other = user_fixture() _pending = beacon_fixture(user, callsign: "W5PND") assert Beacons.list_beacons_for_user(user, other) == [] end test "shows pending beacons when the viewer is an admin" do user = user_fixture() admin = %{user_fixture() | is_admin: true} pending = beacon_fixture(user, callsign: "W5PND") assert [found] = Beacons.list_beacons_for_user(user, admin) assert found.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