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.
336 lines
12 KiB
Elixir
336 lines
12 KiB
Elixir
defmodule Microwaveprop.Radio.AdifImportTest do
|
|
use Microwaveprop.DataCase, async: true
|
|
|
|
alias Microwaveprop.Radio.AdifImport
|
|
alias Microwaveprop.Terrain.ElevationClient
|
|
alias Microwaveprop.Weather.HrrrClient
|
|
alias Microwaveprop.Weather.IemClient
|
|
|
|
setup do
|
|
Req.Test.stub(IemClient, fn conn ->
|
|
case conn.request_path do
|
|
"/cgi-bin/request/asos.py" -> Req.Test.text(conn, "#DEBUG,\nstation,valid,tmpf\n")
|
|
_ -> Req.Test.json(conn, %{"profiles" => []})
|
|
end
|
|
end)
|
|
|
|
Req.Test.stub(HrrrClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 404, "not found")
|
|
end)
|
|
|
|
Req.Test.stub(ElevationClient, fn conn ->
|
|
params = Plug.Conn.fetch_query_params(conn).query_params
|
|
lat_count = params["latitude"] |> String.split(",") |> length()
|
|
Req.Test.json(conn, %{"elevation" => List.duplicate(200.0, lat_count)})
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
@submitter "test@example.com"
|
|
|
|
@valid_adif """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
describe "preview/2" do
|
|
test "parses a valid ADIF record" do
|
|
assert {:ok, preview} = AdifImport.preview(@valid_adif, @submitter)
|
|
assert length(preview.valid) == 1
|
|
assert preview.invalid == []
|
|
assert preview.duplicates == []
|
|
|
|
[row] = preview.valid
|
|
assert row.attrs["station1"] == "W5XD"
|
|
assert row.attrs["station2"] == "K5TR"
|
|
assert row.attrs["grid1"] == "EM12"
|
|
assert row.attrs["grid2"] == "EM00"
|
|
assert row.attrs["band"] == "10000"
|
|
assert row.attrs["mode"] == "CW"
|
|
end
|
|
|
|
test "parses ADIF with BAND field instead of FREQ" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<BAND:4>23cm<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.attrs["band"] == "1296"
|
|
end
|
|
|
|
test "accepts 33cm ADIF band name and maps it to 902 MHz" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<BAND:4>33cm<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.attrs["band"] == "902"
|
|
end
|
|
|
|
test "maps a 902 MHz FREQ value onto the 902 band" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:7>903.100<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.attrs["band"] == "902"
|
|
end
|
|
|
|
test "maps ADIF frequency to correct band" do
|
|
for {freq, expected_band} <- [
|
|
{"1296.200", "1296"},
|
|
{"2304.000", "2304"},
|
|
{"5760.100", "5760"},
|
|
{"10368.000", "10000"},
|
|
{"24192.000", "24000"},
|
|
{"47088.000", "47000"},
|
|
{"76032.000", "75000"}
|
|
] do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:#{byte_size(freq)}>#{freq}<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
|
|
assert row.attrs["band"] == expected_band,
|
|
"FREQ #{freq} should map to band #{expected_band}, got #{row.attrs["band"]}"
|
|
end
|
|
end
|
|
|
|
test "handles multiple records" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
<STATION_CALLSIGN:4>W5XD<CALL:5>N5DUP<GRIDSQUARE:4>EM13<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:3>SSB<QSO_DATE:8>20260328<TIME_ON:6>190000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert length(preview.valid) == 2
|
|
end
|
|
|
|
test "skips ADIF header before <EOH>" do
|
|
adif = """
|
|
ADIF Export from Logger32
|
|
<ADIF_VER:5>3.1.4
|
|
<PROGRAMID:8>Logger32
|
|
<EOH>
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert length(preview.valid) == 1
|
|
end
|
|
|
|
test "returns error for empty ADIF" do
|
|
assert {:error, :no_records} = AdifImport.preview("", @submitter)
|
|
end
|
|
|
|
test "returns error for header-only ADIF" do
|
|
adif = """
|
|
ADIF Export
|
|
<ADIF_VER:5>3.1.4
|
|
<EOH>
|
|
"""
|
|
|
|
assert {:error, :no_records} = AdifImport.preview(adif, @submitter)
|
|
end
|
|
|
|
test "marks record invalid when missing required fields" do
|
|
adif = """
|
|
<CALL:4>K5TR<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert preview.valid == []
|
|
assert length(preview.invalid) == 1
|
|
end
|
|
|
|
test "skips non-microwave contacts" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:7>144.200<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert preview.valid == []
|
|
assert length(preview.invalid) == 1
|
|
end
|
|
|
|
test "deduplicates against existing DB contacts" do
|
|
# Insert one via CSV import path
|
|
{:ok, _} =
|
|
Microwaveprop.Radio.create_contact(%{
|
|
station1: "W5XD",
|
|
station2: "K5TR",
|
|
grid1: "EM12",
|
|
grid2: "EM00",
|
|
band: "10000",
|
|
mode: "CW",
|
|
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
|
submitter_email: @submitter
|
|
})
|
|
|
|
assert {:ok, preview} = AdifImport.preview(@valid_adif, @submitter)
|
|
assert preview.valid == []
|
|
assert length(preview.duplicates) == 1
|
|
end
|
|
|
|
test "is case-insensitive for field names" do
|
|
adif = """
|
|
<station_callsign:4>W5XD<call:4>K5TR<gridsquare:4>EM00<my_gridsquare:4>EM12<freq:9>10368.000<mode:2>CW<qso_date:8>20260328<time_on:6>180000<eor>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert length(preview.valid) == 1
|
|
end
|
|
|
|
test "handles TIME_ON with 4 digits (HHMM)" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:4>1800<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.timestamp == ~U[2026-03-28 18:00:00Z]
|
|
end
|
|
|
|
test "uses OPERATOR field when STATION_CALLSIGN is missing" do
|
|
adif = """
|
|
<OPERATOR:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.attrs["station1"] == "W5XD"
|
|
end
|
|
|
|
test "normalizes gridsquare case to uppercase" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>em00<MY_GRIDSQUARE:6>em12kp<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.attrs["grid1"] == "EM12KP"
|
|
assert row.attrs["grid2"] == "EM00"
|
|
end
|
|
|
|
test "parses file with header tags but no <EOH>" do
|
|
adif = """
|
|
<ADIF_VER:5>3.1.4
|
|
<PROGRAMID:8>Logger32
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert length(preview.valid) == 1
|
|
end
|
|
|
|
test "strips APP_ prefix from app-defined fields" do
|
|
adif = """
|
|
<APP_N1MM_STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<APP_MYLOG_GRIDSQUARE:4>EM00<APP_MYLOG_MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.attrs["station1"] == "W5XD"
|
|
assert row.attrs["grid1"] == "EM12"
|
|
assert row.attrs["grid2"] == "EM00"
|
|
end
|
|
|
|
test "standard fields take precedence over app-defined" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<APP_FOO_STATION_CALLSIGN:5>WRONG<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.attrs["station1"] == "W5XD"
|
|
end
|
|
|
|
test "normalizes USB / LSB / DSB to SSB" do
|
|
for sideband <- ~w(USB LSB DSB) do
|
|
adif =
|
|
"<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:#{byte_size(sideband)}>#{sideband}<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>"
|
|
|
|
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
|
|
assert row.attrs["mode"] == "SSB", "expected #{sideband} to map to SSB"
|
|
end
|
|
end
|
|
|
|
test "normalizes NBFM / WFM to FM" do
|
|
for variant <- ~w(NBFM WFM) do
|
|
adif =
|
|
"<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:#{byte_size(variant)}>#{variant}<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>"
|
|
|
|
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
|
|
assert row.attrs["mode"] == "FM"
|
|
end
|
|
end
|
|
|
|
test "uses SUBMODE when MODE is a generic digital mode" do
|
|
# ADIF convention: digital modes are reported as MODE=MFSK, SUBMODE=FT8
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:4>MFSK<SUBMODE:3>FT8<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
|
|
assert row.attrs["mode"] == "FT8"
|
|
end
|
|
|
|
test "SUBMODE takes precedence when both are allowed values" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:3>MFSK<SUBMODE:3>FT4<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
|
|
assert row.attrs["mode"] == "FT4"
|
|
end
|
|
|
|
test "unknown modes are stored as nil so the row still imports" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:4>RTTY<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
|
|
assert row.attrs["mode"] == nil
|
|
end
|
|
|
|
test "missing MODE imports as nil" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter)
|
|
assert row.attrs["mode"] == nil
|
|
end
|
|
|
|
test "parses file with no header at all" do
|
|
adif = """
|
|
<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert length(preview.valid) == 1
|
|
end
|
|
|
|
test "parses records containing UTF-8 characters in earlier fields" do
|
|
# Regression: Regex.scan with `return: :index` returns byte offsets,
|
|
# but String.slice/3 uses character offsets. A multi-byte UTF-8 char
|
|
# (é = 2 bytes) in a NOTES field earlier in the record used to shift
|
|
# every subsequent field's character-based slice one byte left,
|
|
# causing a crash when the size slice landed on a ">" character.
|
|
adif = """
|
|
<NOTES:6>caf\u00e9 QSO<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:4>EM12<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180000<EOR>
|
|
"""
|
|
|
|
assert {:ok, preview} = AdifImport.preview(adif, @submitter)
|
|
assert [row] = preview.valid
|
|
assert row.attrs["station1"] == "W5XD"
|
|
assert row.attrs["station2"] == "K5TR"
|
|
assert row.attrs["band"] == "10000"
|
|
end
|
|
end
|
|
end
|