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