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 """ W5XDK5TREM00EM1210368.000CW20260328180000 """ 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 == [] assert preview.refinements == [] [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 "captures PROP_MODE into user_declared_prop_mode" do adif = """ W5XDK5TREM00EM1223cmCWES20260605180000 """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert [row] = preview.valid assert row.attrs["user_declared_prop_mode"] == "ES" end test "missing PROP_MODE keeps user_declared_prop_mode nil" do assert {:ok, preview} = AdifImport.preview(@valid_adif, @submitter) [row] = preview.valid assert row.attrs["user_declared_prop_mode"] == nil end test "strips non-printables (zero-width, BOM, control) from ADIF field values" do # Zero-width space (U+200B = 0xE2 0x80 0x8B, 3 bytes) inside CALL and # GRIDSQUARE. ADIF length tags count BYTES so the tag widths include # the multi-byte payload — sanitization strips the zero-width chars # and leaves the underlying ASCII intact. adif = "W5XD" <> "K5\u200BTR" <> "EM\u200B00" <> "EM12" <> "10368.000CW20260328180000\n" assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert [row] = preview.valid assert row.attrs["station2"] == "K5TR" assert row.attrs["grid2"] == "EM00" end test "captures ADIF NOTES into notes" do adif = """ W5XDK5TREM00EM1210368.000CW20260328180000Rare Es along TX/OK front """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert [row] = preview.valid assert row.attrs["notes"] == "Rare Es along TX/OK front" end test "falls back to ADIF COMMENT when NOTES is absent" do adif = """ W5XDK5TREM00EM1210368.000CW20260328180000nice qso!! """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert [row] = preview.valid assert row.attrs["notes"] == "nice qso!!" end test "prefers NOTES over COMMENT when both are present" do adif = """ W5XDK5TREM00EM1210368.000CW20260328180000detailedshort """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert [row] = preview.valid assert row.attrs["notes"] == "detailed" end test "missing NOTES / COMMENT leaves notes nil" do assert {:ok, preview} = AdifImport.preview(@valid_adif, @submitter) [row] = preview.valid assert row.attrs["notes"] == nil end test "parses ADIF with BAND field instead of FREQ" do adif = """ W5XDK5TREM00EM1223cmCW20260328180000 """ 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 = """ W5XDK5TREM00EM1233cmCW20260328180000 """ 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 = """ W5XDK5TREM00EM12903.100CW20260328180000 """ 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 = """ W5XDK5TREM00EM12#{freq}CW20260328180000 """ 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 = """ W5XDK5TREM00EM1210368.000CW20260328180000 W5XDN5DUPEM13EM1210368.000SSB20260328190000 """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert length(preview.valid) == 2 end test "skips ADIF header before " do adif = """ ADIF Export from Logger32 3.1.4 Logger32 W5XDK5TREM00EM1210368.000CW20260328180000 """ 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 3.1.4 """ assert {:error, :no_records} = AdifImport.preview(adif, @submitter) end test "marks record invalid when missing required fields" do adif = """ K5TRCW20260328180000 """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert preview.valid == [] assert length(preview.invalid) == 1 end test "skips HF contacts" do adif = """ W5XDK5TREM00EM1214.200CW20260328180000 """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert preview.valid == [] assert length(preview.invalid) == 1 end test "accepts 2m contacts" do adif = """ W5XDK5TREM00EM12144.200CW20260328180000 """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert [%{attrs: %{"band" => "144"}}] = preview.valid 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 preview.refinements == [] assert length(preview.duplicates) == 1 end test "upload with longer grid than existing routes to refinements" do {:ok, existing} = 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 }) adif = "W5XDK5TREM00EM12kp3710368.000CW20260328180500" assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert preview.valid == [] assert preview.duplicates == [] assert [ref] = preview.refinements assert ref.existing_id == existing.id assert ref.changes == %{grid1: "EM12KP37"} end test "is case-insensitive for field names" do adif = """ W5XDK5TREM00EM1210368.000CW20260328180000 """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert length(preview.valid) == 1 end test "handles TIME_ON with 4 digits (HHMM)" do adif = """ W5XDK5TREM00EM1210368.000CW202603281800 """ 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 = """ W5XDK5TREM00EM1210368.000CW20260328180000 """ 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 = """ W5XDK5TRem00em12kp10368.000CW20260328180000 """ 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 " do adif = """ 3.1.4 Logger32 W5XDK5TREM00EM1210368.000CW20260328180000 """ assert {:ok, preview} = AdifImport.preview(adif, @submitter) assert length(preview.valid) == 1 end test "strips APP_ prefix from app-defined fields" do adif = """ W5XDK5TREM00EM1210368.000CW20260328180000 """ 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 = """ W5XDWRONGK5TREM00EM1210368.000CW20260328180000 """ 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 = "W5XDK5TREM00EM1210368.000#{sideband}20260328180000" 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 = "W5XDK5TREM00EM1210368.000#{variant}20260328180000" 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 = """ W5XDK5TREM00EM1210368.000MFSKFT820260328180000 """ 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 = """ W5XDK5TREM00EM1210368.000MFSKFT420260328180000 """ 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 = """ W5XDK5TREM00EM1210368.000RTTY20260328180000 """ assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter) assert row.attrs["mode"] == nil end test "missing MODE imports as nil" do adif = """ W5XDK5TREM00EM1210368.00020260328180000 """ assert {:ok, %{valid: [row]}} = AdifImport.preview(adif, @submitter) assert row.attrs["mode"] == nil end test "parses file with no header at all" do adif = """ W5XDK5TREM00EM1210368.000CW20260328180000 """ 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 = """ caf\u00e9 QSOW5XDK5TREM00EM1210368.000CW20260328180000 """ 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