prop/test/microwaveprop/radio/import_matcher_test.exs
Graham McIntire bbeb95ff5d
CSV/ADIF import: upsert existing contacts on grid/mode refinement
Widen dedup match to 4-char grid prefix so an upload with a longer grid
(e.g. FN42aa25) finds the existing FN42 contact. Within a match, classify
as a refinement when the upload strictly extends the existing grid or
fills a missing mode; as a contradiction (still skipped) when grids or
modes genuinely disagree. Refinements flow through a new preview bucket,
update the existing row in place, and re-enqueue enrichment when the
position changed.
2026-04-16 10:57:37 -05:00

189 lines
6.7 KiB
Elixir

defmodule Microwaveprop.Radio.ImportMatcherTest do
use ExUnit.Case, async: true
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ImportMatcher
defp build_contact(attrs \\ %{}) do
defaults = %{
id: Ecto.UUID.generate(),
station1: "W5XD",
station2: "K5TR",
grid1: "EM12",
grid2: "EM00",
band: Decimal.new("10000"),
mode: "CW",
qso_timestamp: ~U[2026-03-28 18:00:00Z]
}
struct(Contact, Map.merge(defaults, attrs))
end
defp upload(attrs) do
defaults = %{
"station1" => "W5XD",
"station2" => "K5TR",
"grid1" => "EM12",
"grid2" => "EM00",
"band" => "10000",
"mode" => "CW",
"qso_timestamp" => "2026-03-28T18:00:00Z"
}
Map.merge(defaults, attrs)
end
describe "prefix_key/5" do
test "is direction-agnostic" do
a = ImportMatcher.prefix_key("W5XD", "EM12kp", "K5TR", "EM00cd", 10_000)
b = ImportMatcher.prefix_key("K5TR", "EM00cd", "W5XD", "EM12kp", 10_000)
assert a == b
end
test "uppercases and trims callsigns and grids" do
a = ImportMatcher.prefix_key(" w5xd ", " em12kp ", " k5tr ", " em00 ", 10_000)
b = ImportMatcher.prefix_key("W5XD", "EM12KP", "K5TR", "EM00", 10_000)
assert a == b
end
test "uses only first 4 characters of the grid" do
with_precision = ImportMatcher.prefix_key("W5XD", "EM12kp", "K5TR", "EM00cd", 10_000)
just_four = ImportMatcher.prefix_key("W5XD", "EM12", "K5TR", "EM00", 10_000)
assert with_precision == just_four
end
test "different band produces different key" do
a = ImportMatcher.prefix_key("W5XD", "EM12", "K5TR", "EM00", 10_000)
b = ImportMatcher.prefix_key("W5XD", "EM12", "K5TR", "EM00", 24_000)
refute a == b
end
test "invalid when any grid is under 4 chars" do
assert ImportMatcher.prefix_key("W5XD", "EM1", "K5TR", "EM00", 10_000) == :invalid
assert ImportMatcher.prefix_key("W5XD", "EM12", "K5TR", "EM", 10_000) == :invalid
assert ImportMatcher.prefix_key("W5XD", nil, "K5TR", "EM00", 10_000) == :invalid
assert ImportMatcher.prefix_key("W5XD", "EM12", "K5TR", nil, 10_000) == :invalid
end
test "band accepted as integer, string, or Decimal" do
int_key = ImportMatcher.prefix_key("W5XD", "EM12", "K5TR", "EM00", 10_000)
str_key = ImportMatcher.prefix_key("W5XD", "EM12", "K5TR", "EM00", "10000")
dec_key = ImportMatcher.prefix_key("W5XD", "EM12", "K5TR", "EM00", Decimal.new("10000"))
assert int_key == str_key
assert int_key == dec_key
end
end
describe "classify_against_existing/2" do
test "identical grids and mode classify as duplicate" do
existing = build_contact()
up = upload(%{})
assert ImportMatcher.classify_against_existing(up, existing) == :duplicate
end
test "upload extends grid1 -> refinement" do
existing = build_contact()
up = upload(%{"grid1" => "EM12kp37"})
assert ImportMatcher.classify_against_existing(up, existing) ==
{:refinement, %{grid1: "EM12KP37"}}
end
test "upload extends grid2 -> refinement" do
existing = build_contact()
up = upload(%{"grid2" => "EM00cd"})
assert ImportMatcher.classify_against_existing(up, existing) ==
{:refinement, %{grid2: "EM00CD"}}
end
test "upload extends both grids -> refinement with both changes" do
existing = build_contact()
up = upload(%{"grid1" => "EM12kp", "grid2" => "EM00cd"})
assert ImportMatcher.classify_against_existing(up, existing) ==
{:refinement, %{grid1: "EM12KP", grid2: "EM00CD"}}
end
test "direction-swapped upload maps changes into existing's orientation" do
existing = build_contact(%{station1: "W5XD", grid1: "EM12", station2: "K5TR", grid2: "EM00"})
up =
upload(%{
"station1" => "K5TR",
"grid1" => "EM00bb",
"station2" => "W5XD",
"grid2" => "EM12aa"
})
assert ImportMatcher.classify_against_existing(up, existing) ==
{:refinement, %{grid1: "EM12AA", grid2: "EM00BB"}}
end
test "shorter-than-existing upload grid -> duplicate (not refinement)" do
existing = build_contact(%{grid1: "EM12kp"})
up = upload(%{"grid1" => "EM12"})
assert ImportMatcher.classify_against_existing(up, existing) == :duplicate
end
test "same-length but different grid -> contradiction" do
existing = build_contact(%{grid1: "EM12"})
up = upload(%{"grid1" => "EM13"})
assert ImportMatcher.classify_against_existing(up, existing) == :contradiction
end
test "existing mode nil + upload mode CW -> mode refinement" do
existing = build_contact(%{mode: nil})
up = upload(%{"mode" => "CW"})
assert ImportMatcher.classify_against_existing(up, existing) ==
{:refinement, %{mode: "CW"}}
end
test "existing mode CW + upload mode SSB -> contradiction" do
existing = build_contact(%{mode: "CW"})
up = upload(%{"mode" => "SSB"})
assert ImportMatcher.classify_against_existing(up, existing) == :contradiction
end
test "existing mode CW + upload mode nil -> duplicate (no change)" do
existing = build_contact(%{mode: "CW"})
up = upload(%{"mode" => nil})
assert ImportMatcher.classify_against_existing(up, existing) == :duplicate
end
test "both modes nil -> duplicate" do
existing = build_contact(%{mode: nil})
up = upload(%{"mode" => nil})
assert ImportMatcher.classify_against_existing(up, existing) == :duplicate
end
test "same callsign on both sides of upload -> duplicate (ambiguous)" do
existing = build_contact(%{station1: "W5XD", station2: "K5TR"})
up = upload(%{"station1" => "W5XD", "station2" => "W5XD", "grid1" => "EM12kp"})
assert ImportMatcher.classify_against_existing(up, existing) == :duplicate
end
test "grid refinement + mode refinement combined returns both keys" do
existing = build_contact(%{grid1: "EM12", mode: nil})
up = upload(%{"grid1" => "EM12kp", "mode" => "CW"})
assert ImportMatcher.classify_against_existing(up, existing) ==
{:refinement, %{grid1: "EM12KP", mode: "CW"}}
end
test "grid contradiction on one side trumps refinement on other" do
existing = build_contact(%{grid1: "EM12", grid2: "EM00"})
up = upload(%{"grid1" => "EM13", "grid2" => "EM00cd"})
assert ImportMatcher.classify_against_existing(up, existing) == :contradiction
end
test "refinement values are uppercased and trimmed" do
existing = build_contact()
up = upload(%{"grid1" => " em12kp "})
assert ImportMatcher.classify_against_existing(up, existing) ==
{:refinement, %{grid1: "EM12KP"}}
end
end
end