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.
189 lines
6.7 KiB
Elixir
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
|