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.
This commit is contained in:
parent
824143ad64
commit
bbeb95ff5d
10 changed files with 1203 additions and 132 deletions
|
|
@ -639,6 +639,101 @@ defmodule Microwaveprop.Radio do
|
|||
end)
|
||||
end
|
||||
|
||||
@refinement_fields ~w(grid1 grid2 mode)a
|
||||
@refinement_allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
|
||||
|
||||
@doc """
|
||||
Apply a grid/mode refinement from an import row to an existing contact.
|
||||
|
||||
`changes` may contain any of `:grid1`, `:grid2`, or `:mode`. Grid changes
|
||||
trigger recomputation of the corresponding `pos` and the `distance_km`,
|
||||
and reset all four enrichment status fields to `:pending` so the caller
|
||||
can re-enqueue the enrichment pipeline. Mode-only changes do not touch
|
||||
positions or enrichment status.
|
||||
"""
|
||||
@spec apply_contact_refinement(Contact.t(), map()) ::
|
||||
{:ok, Contact.t()} | {:error, Ecto.Changeset.t()}
|
||||
def apply_contact_refinement(%Contact{} = contact, changes) when is_map(changes) do
|
||||
cleaned = Map.take(changes, @refinement_fields)
|
||||
|
||||
if cleaned == %{} do
|
||||
{:ok, contact}
|
||||
else
|
||||
do_apply_contact_refinement(contact, cleaned)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_apply_contact_refinement(contact, changes) do
|
||||
changeset =
|
||||
contact
|
||||
|> Ecto.Changeset.cast(changes, @refinement_fields)
|
||||
|> validate_refinement_mode()
|
||||
|> maybe_recompute_refinement_positions(contact, changes)
|
||||
|
||||
if changeset.valid? do
|
||||
Repo.update(changeset)
|
||||
else
|
||||
{:error, %{changeset | action: :update}}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_refinement_mode(changeset) do
|
||||
Ecto.Changeset.validate_inclusion(changeset, :mode, @refinement_allowed_modes)
|
||||
end
|
||||
|
||||
defp maybe_recompute_refinement_positions(changeset, contact, changes) do
|
||||
new_grid1 = Map.get(changes, :grid1)
|
||||
new_grid2 = Map.get(changes, :grid2)
|
||||
|
||||
cond do
|
||||
new_grid1 && new_grid2 ->
|
||||
put_pos_changes(changeset, new_grid1, new_grid2, :both)
|
||||
|
||||
new_grid1 ->
|
||||
put_pos_changes(changeset, new_grid1, contact.grid2, :side1)
|
||||
|
||||
new_grid2 ->
|
||||
put_pos_changes(changeset, contact.grid1, new_grid2, :side2)
|
||||
|
||||
true ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp put_pos_changes(changeset, grid1, grid2, sides) do
|
||||
case {Maidenhead.to_latlon(grid1), Maidenhead.to_latlon(grid2)} do
|
||||
{{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} ->
|
||||
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
|
||||
|
||||
changeset
|
||||
|> maybe_put_pos_for_side(:pos1, %{"lat" => lat1, "lon" => lon1}, sides)
|
||||
|> maybe_put_pos_for_side(:pos2, %{"lat" => lat2, "lon" => lon2}, sides)
|
||||
|> Ecto.Changeset.put_change(:distance_km, distance)
|
||||
|> reset_all_enrichment_statuses()
|
||||
|
||||
_ ->
|
||||
Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_pos_for_side(changeset, :pos1, pos, sides) when sides in [:side1, :both] do
|
||||
Ecto.Changeset.put_change(changeset, :pos1, pos)
|
||||
end
|
||||
|
||||
defp maybe_put_pos_for_side(changeset, :pos2, pos, sides) when sides in [:side2, :both] do
|
||||
Ecto.Changeset.put_change(changeset, :pos2, pos)
|
||||
end
|
||||
|
||||
defp maybe_put_pos_for_side(changeset, _field, _pos, _sides), do: changeset
|
||||
|
||||
defp reset_all_enrichment_statuses(changeset) do
|
||||
changeset
|
||||
|> Ecto.Changeset.put_change(:hrrr_status, :pending)
|
||||
|> Ecto.Changeset.put_change(:weather_status, :pending)
|
||||
|> Ecto.Changeset.put_change(:terrain_status, :pending)
|
||||
|> Ecto.Changeset.put_change(:iemre_status, :pending)
|
||||
end
|
||||
|
||||
# ── Contact Edits ───────────────────────────────────────────────
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ defmodule Microwaveprop.Radio.AdifImport do
|
|||
|
||||
alias Microwaveprop.Radio.BandResolver
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Radio.ImportMatcher
|
||||
alias Microwaveprop.Repo
|
||||
|
||||
@dedup_window_seconds 3600
|
||||
|
|
@ -90,6 +91,7 @@ defmodule Microwaveprop.Radio.AdifImport do
|
|||
valid: [map()],
|
||||
invalid: [map()],
|
||||
duplicates: [map()],
|
||||
refinements: [map()],
|
||||
total_rows: non_neg_integer(),
|
||||
submitter_email: String.t()
|
||||
}
|
||||
|
|
@ -104,13 +106,14 @@ defmodule Microwaveprop.Radio.AdifImport do
|
|||
rows = records |> Enum.with_index(1) |> Enum.map(&build_row(&1, submitter_email))
|
||||
{valid, invalid} = split_parsed(rows)
|
||||
existing = load_existing_for_dedup(valid)
|
||||
{unique, duplicates} = dedupe(valid, existing)
|
||||
{unique, duplicates, refinements} = dedupe(valid, existing)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
valid: unique,
|
||||
invalid: invalid,
|
||||
duplicates: duplicates,
|
||||
refinements: refinements,
|
||||
total_rows: length(records),
|
||||
submitter_email: submitter_email
|
||||
}}
|
||||
|
|
@ -284,52 +287,69 @@ defmodule Microwaveprop.Radio.AdifImport do
|
|||
from(c in Contact,
|
||||
where:
|
||||
c.band in ^bands and c.qso_timestamp >= ^min_ts and c.qso_timestamp <= ^max_ts and
|
||||
c.flagged_invalid == false,
|
||||
select: %{
|
||||
station1: c.station1,
|
||||
station2: c.station2,
|
||||
grid1: c.grid1,
|
||||
grid2: c.grid2,
|
||||
band: c.band,
|
||||
qso_timestamp: c.qso_timestamp
|
||||
}
|
||||
c.flagged_invalid == false
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.reduce(%{}, fn row, acc ->
|
||||
key = dedup_key(row.station1, row.grid1, row.station2, row.grid2, row.band)
|
||||
Map.update(acc, key, [row.qso_timestamp], &[row.qso_timestamp | &1])
|
||||
|> Enum.reduce(%{}, fn contact, acc ->
|
||||
case ImportMatcher.prefix_key(contact.station1, contact.grid1, contact.station2, contact.grid2, contact.band) do
|
||||
:invalid -> acc
|
||||
key -> Map.update(acc, key, [contact], &[contact | &1])
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp dedupe(rows, db_map) do
|
||||
{accepted, _in_batch, duplicates} =
|
||||
Enum.reduce(rows, {[], %{}, []}, fn row, {accepted, in_batch, dups} ->
|
||||
key =
|
||||
dedup_key(
|
||||
row.attrs["station1"],
|
||||
row.attrs["grid1"],
|
||||
row.attrs["station2"],
|
||||
row.attrs["grid2"],
|
||||
row.attrs["band"]
|
||||
)
|
||||
{accepted, _in_batch, duplicates, refinements} =
|
||||
Enum.reduce(rows, {[], %{}, [], []}, &classify_row(&1, &2, db_map))
|
||||
|
||||
cond do
|
||||
conflict_in_map?(db_map, key, row.timestamp) ->
|
||||
{accepted, in_batch, [Map.put(row, :reason, :existing_contact) | dups]}
|
||||
|
||||
conflict_in_map?(in_batch, key, row.timestamp) ->
|
||||
{accepted, in_batch, [Map.put(row, :reason, :earlier_in_upload) | dups]}
|
||||
|
||||
true ->
|
||||
new_batch = Map.update(in_batch, key, [row.timestamp], &[row.timestamp | &1])
|
||||
{[row | accepted], new_batch, dups}
|
||||
end
|
||||
end)
|
||||
|
||||
{Enum.reverse(accepted), Enum.reverse(duplicates)}
|
||||
{Enum.reverse(accepted), Enum.reverse(duplicates), Enum.reverse(refinements)}
|
||||
end
|
||||
|
||||
defp conflict_in_map?(map, key, timestamp) do
|
||||
defp classify_row(row, {accepted, in_batch, dups, refs}, db_map) do
|
||||
key = prefix_key_from_attrs(row.attrs)
|
||||
|
||||
cond do
|
||||
key == :invalid ->
|
||||
{[row | accepted], in_batch, dups, refs}
|
||||
|
||||
candidate = db_candidate(db_map, key, row.timestamp) ->
|
||||
classify_db_candidate(row, candidate, accepted, in_batch, dups, refs)
|
||||
|
||||
batch_conflict?(in_batch, key, row.timestamp) ->
|
||||
{accepted, in_batch, [Map.put(row, :reason, :earlier_in_upload) | dups], refs}
|
||||
|
||||
true ->
|
||||
new_batch = Map.update(in_batch, key, [row.timestamp], &[row.timestamp | &1])
|
||||
{[row | accepted], new_batch, dups, refs}
|
||||
end
|
||||
end
|
||||
|
||||
defp classify_db_candidate(row, candidate, accepted, in_batch, dups, refs) do
|
||||
case ImportMatcher.classify_against_existing(row.attrs, candidate) do
|
||||
{:refinement, changes} ->
|
||||
refinement = %{
|
||||
row_num: row.row_num,
|
||||
attrs: row.attrs,
|
||||
timestamp: row.timestamp,
|
||||
existing_id: candidate.id,
|
||||
changes: changes
|
||||
}
|
||||
|
||||
{accepted, in_batch, dups, [refinement | refs]}
|
||||
|
||||
_ ->
|
||||
{accepted, in_batch, [Map.put(row, :reason, :existing_contact) | dups], refs}
|
||||
end
|
||||
end
|
||||
|
||||
defp db_candidate(db_map, key, timestamp) do
|
||||
case Map.fetch(db_map, key) do
|
||||
:error -> nil
|
||||
{:ok, contacts} -> Enum.find(contacts, &within_window?(&1.qso_timestamp, timestamp))
|
||||
end
|
||||
end
|
||||
|
||||
defp batch_conflict?(map, key, timestamp) do
|
||||
case Map.fetch(map, key) do
|
||||
:error -> false
|
||||
{:ok, timestamps} -> Enum.any?(timestamps, &within_window?(&1, timestamp))
|
||||
|
|
@ -340,20 +360,16 @@ defmodule Microwaveprop.Radio.AdifImport do
|
|||
abs(DateTime.diff(t1, t2, :second)) <= @dedup_window_seconds
|
||||
end
|
||||
|
||||
defp dedup_key(s1, g1, s2, g2, band) do
|
||||
a = {upcase(s1), upcase(g1)}
|
||||
b = {upcase(s2), upcase(g2)}
|
||||
[c1, c2] = Enum.sort([a, b])
|
||||
{c1, c2, normalize_band(band)}
|
||||
defp prefix_key_from_attrs(attrs) do
|
||||
ImportMatcher.prefix_key(
|
||||
attrs["station1"],
|
||||
attrs["grid1"],
|
||||
attrs["station2"],
|
||||
attrs["grid2"],
|
||||
attrs["band"]
|
||||
)
|
||||
end
|
||||
|
||||
defp upcase(nil), do: ""
|
||||
defp upcase(v), do: v |> to_string() |> String.trim() |> String.upcase()
|
||||
|
||||
defp normalize_band(%Decimal{} = b), do: Decimal.to_integer(b)
|
||||
defp normalize_band(b) when is_integer(b), do: b
|
||||
defp normalize_band(b) when is_binary(b), do: b |> String.trim() |> String.to_integer()
|
||||
|
||||
defp changeset_error_strings(changeset) do
|
||||
changeset
|
||||
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Radio.BandResolver
|
||||
alias Microwaveprop.Radio.Contact
|
||||
alias Microwaveprop.Radio.ImportMatcher
|
||||
alias Microwaveprop.Repo
|
||||
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
||||
|
||||
|
|
@ -60,6 +61,7 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
valid: [map()],
|
||||
invalid: [map()],
|
||||
duplicates: [map()],
|
||||
refinements: [map()],
|
||||
total_rows: non_neg_integer(),
|
||||
submitter_email: String.t()
|
||||
}
|
||||
|
|
@ -71,6 +73,7 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
|
||||
@type commit_result :: %{
|
||||
imported: [Contact.t()],
|
||||
refined: [Contact.t()],
|
||||
errors: [{pos_integer(), [String.t()]}]
|
||||
}
|
||||
|
||||
|
|
@ -80,10 +83,9 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
| {:error, :no_data_rows}
|
||||
| {:error, {:missing_required_columns, [String.t()]}}
|
||||
def import(csv_string, submitter_email) do
|
||||
with {:ok, rows} <- parse_csv(csv_string, submitter_email) do
|
||||
{valid, invalid} = split_parsed(rows)
|
||||
{:ok, commit_result} = commit(valid)
|
||||
invalid_errors = Enum.map(invalid, fn %{row_num: n, messages: m} -> {n, m} end)
|
||||
with {:ok, preview} <- preview(csv_string, submitter_email) do
|
||||
{:ok, commit_result} = commit(%{valid: preview.valid, refinements: preview.refinements})
|
||||
invalid_errors = Enum.map(preview.invalid, fn %{row_num: n, messages: m} -> {n, m} end)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
|
|
@ -118,13 +120,14 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
with {:ok, rows} <- parse_csv(csv_string, submitter_email) do
|
||||
{valid, invalid} = split_parsed(rows)
|
||||
existing = load_existing_for_dedup(valid)
|
||||
{unique, duplicates} = dedupe(valid, existing)
|
||||
{unique, duplicates, refinements} = dedupe(valid, existing)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
valid: unique,
|
||||
invalid: invalid,
|
||||
duplicates: duplicates,
|
||||
refinements: refinements,
|
||||
total_rows: length(rows),
|
||||
submitter_email: submitter_email
|
||||
}}
|
||||
|
|
@ -132,25 +135,79 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Inserts the rows returned by `preview/2` in `:valid`. Each is passed to
|
||||
`Radio.create_contact/1` and its enrichment is enqueued on success.
|
||||
Returns `{:ok, %{imported: [...], errors: [{row_num, messages}, ...]}}`.
|
||||
Commit a preview. Accepts either a list of valid rows (legacy) or a map with
|
||||
`:valid` and `:refinements` keys. Valid rows are inserted via
|
||||
`Radio.create_contact/1`. Refinements are applied to their matching existing
|
||||
contacts via `Radio.apply_contact_refinement/2`; grid-changing refinements
|
||||
re-enqueue enrichment, mode-only refinements do not.
|
||||
|
||||
Returns `{:ok, %{imported: [...], refined: [...], errors: [{row_num, messages}, ...]}}`.
|
||||
"""
|
||||
@spec commit([map()]) :: {:ok, commit_result()}
|
||||
@spec commit([map()] | %{valid: [map()], refinements: [map()]}) :: {:ok, commit_result()}
|
||||
def commit(valid_rows) when is_list(valid_rows) do
|
||||
commit(%{valid: valid_rows, refinements: []})
|
||||
end
|
||||
|
||||
def commit(%{valid: valid_rows, refinements: refinements}) do
|
||||
init = %{imported: [], refined: [], errors: []}
|
||||
|
||||
result =
|
||||
Enum.reduce(valid_rows, %{imported: [], errors: []}, fn row, acc ->
|
||||
case Radio.create_contact(row.attrs) do
|
||||
{:ok, contact} ->
|
||||
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
||||
%{acc | imported: [contact | acc.imported]}
|
||||
init
|
||||
|> apply_valid_rows(valid_rows)
|
||||
|> apply_refinements(refinements)
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
%{acc | errors: [{row.row_num, changeset_error_strings(changeset)} | acc.errors]}
|
||||
end
|
||||
end)
|
||||
{:ok,
|
||||
%{
|
||||
imported: Enum.reverse(result.imported),
|
||||
refined: Enum.reverse(result.refined),
|
||||
errors: Enum.reverse(result.errors)
|
||||
}}
|
||||
end
|
||||
|
||||
{:ok, %{imported: Enum.reverse(result.imported), errors: Enum.reverse(result.errors)}}
|
||||
defp apply_valid_rows(acc, valid_rows) do
|
||||
Enum.reduce(valid_rows, acc, fn row, acc ->
|
||||
case Radio.create_contact(row.attrs) do
|
||||
{:ok, contact} ->
|
||||
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
||||
%{acc | imported: [contact | acc.imported]}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
%{acc | errors: [{row.row_num, changeset_error_strings(changeset)} | acc.errors]}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp apply_refinements(acc, refinements) do
|
||||
Enum.reduce(refinements, acc, &apply_refinement_row/2)
|
||||
end
|
||||
|
||||
defp apply_refinement_row(ref, acc) do
|
||||
case Repo.get(Contact, ref.existing_id) do
|
||||
nil ->
|
||||
%{acc | errors: [{ref.row_num, ["existing contact no longer exists"]} | acc.errors]}
|
||||
|
||||
%Contact{} = contact ->
|
||||
apply_refinement_to_contact(contact, ref, acc)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_refinement_to_contact(contact, ref, acc) do
|
||||
case Radio.apply_contact_refinement(contact, ref.changes) do
|
||||
{:ok, refined} ->
|
||||
maybe_reenqueue_after_refinement(refined, ref.changes)
|
||||
%{acc | refined: [refined | acc.refined]}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
%{acc | errors: [{ref.row_num, changeset_error_strings(changeset)} | acc.errors]}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_reenqueue_after_refinement(contact, changes) do
|
||||
if Map.has_key?(changes, :grid1) or Map.has_key?(changes, :grid2) do
|
||||
ContactWeatherEnqueueWorker.enqueue_for_contact(contact)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# -- parsing ---------------------------------------------------------------
|
||||
|
|
@ -305,48 +362,72 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
from(c in Contact,
|
||||
where:
|
||||
c.band in ^bands and c.qso_timestamp >= ^min_ts and c.qso_timestamp <= ^max_ts and
|
||||
c.flagged_invalid == false,
|
||||
select: %{
|
||||
station1: c.station1,
|
||||
station2: c.station2,
|
||||
grid1: c.grid1,
|
||||
grid2: c.grid2,
|
||||
band: c.band,
|
||||
qso_timestamp: c.qso_timestamp
|
||||
}
|
||||
c.flagged_invalid == false
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.reduce(%{}, fn row, acc ->
|
||||
key = dedup_key(row.station1, row.grid1, row.station2, row.grid2, row.band)
|
||||
Map.update(acc, key, [row.qso_timestamp], &[row.qso_timestamp | &1])
|
||||
|> Enum.reduce(%{}, fn contact, acc ->
|
||||
case ImportMatcher.prefix_key(contact.station1, contact.grid1, contact.station2, contact.grid2, contact.band) do
|
||||
:invalid ->
|
||||
acc
|
||||
|
||||
key ->
|
||||
Map.update(acc, key, [contact], &[contact | &1])
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp dedupe(rows, db_map) do
|
||||
# in_batch_map accumulates keys from rows already accepted in this CSV so
|
||||
# we can distinguish DB duplicates from earlier-row duplicates when we
|
||||
# report them.
|
||||
{accepted, _in_batch, duplicates} =
|
||||
Enum.reduce(rows, {[], %{}, []}, fn row, {accepted, in_batch, dups} ->
|
||||
key = dedup_key_from_attrs(row.attrs)
|
||||
{accepted, _in_batch, duplicates, refinements} =
|
||||
Enum.reduce(rows, {[], %{}, [], []}, &classify_row(&1, &2, db_map))
|
||||
|
||||
cond do
|
||||
conflict_in_map?(db_map, key, row.timestamp) ->
|
||||
{accepted, in_batch, [Map.put(row, :reason, :existing_contact) | dups]}
|
||||
|
||||
conflict_in_map?(in_batch, key, row.timestamp) ->
|
||||
{accepted, in_batch, [Map.put(row, :reason, :earlier_in_upload) | dups]}
|
||||
|
||||
true ->
|
||||
new_batch = Map.update(in_batch, key, [row.timestamp], &[row.timestamp | &1])
|
||||
{[row | accepted], new_batch, dups}
|
||||
end
|
||||
end)
|
||||
|
||||
{Enum.reverse(accepted), Enum.reverse(duplicates)}
|
||||
{Enum.reverse(accepted), Enum.reverse(duplicates), Enum.reverse(refinements)}
|
||||
end
|
||||
|
||||
defp conflict_in_map?(map, key, timestamp) do
|
||||
defp classify_row(row, {accepted, in_batch, dups, refs}, db_map) do
|
||||
key = prefix_key_from_attrs(row.attrs)
|
||||
|
||||
cond do
|
||||
key == :invalid ->
|
||||
{[row | accepted], in_batch, dups, refs}
|
||||
|
||||
candidate = db_candidate(db_map, key, row.timestamp) ->
|
||||
classify_db_candidate(row, candidate, accepted, in_batch, dups, refs)
|
||||
|
||||
batch_conflict?(in_batch, key, row.timestamp) ->
|
||||
{accepted, in_batch, [Map.put(row, :reason, :earlier_in_upload) | dups], refs}
|
||||
|
||||
true ->
|
||||
new_batch = Map.update(in_batch, key, [row.timestamp], &[row.timestamp | &1])
|
||||
{[row | accepted], new_batch, dups, refs}
|
||||
end
|
||||
end
|
||||
|
||||
defp classify_db_candidate(row, candidate, accepted, in_batch, dups, refs) do
|
||||
case ImportMatcher.classify_against_existing(row.attrs, candidate) do
|
||||
{:refinement, changes} ->
|
||||
refinement = %{
|
||||
row_num: row.row_num,
|
||||
attrs: row.attrs,
|
||||
timestamp: row.timestamp,
|
||||
existing_id: candidate.id,
|
||||
changes: changes
|
||||
}
|
||||
|
||||
{accepted, in_batch, dups, [refinement | refs]}
|
||||
|
||||
_ ->
|
||||
{accepted, in_batch, [Map.put(row, :reason, :existing_contact) | dups], refs}
|
||||
end
|
||||
end
|
||||
|
||||
defp db_candidate(db_map, key, timestamp) do
|
||||
case Map.fetch(db_map, key) do
|
||||
:error -> nil
|
||||
{:ok, contacts} -> Enum.find(contacts, &within_window?(&1.qso_timestamp, timestamp))
|
||||
end
|
||||
end
|
||||
|
||||
defp batch_conflict?(map, key, timestamp) do
|
||||
case Map.fetch(map, key) do
|
||||
:error -> false
|
||||
{:ok, timestamps} -> Enum.any?(timestamps, &within_window?(&1, timestamp))
|
||||
|
|
@ -357,28 +438,16 @@ defmodule Microwaveprop.Radio.CsvImport do
|
|||
abs(DateTime.diff(t1, t2, :second)) <= @dedup_window_seconds
|
||||
end
|
||||
|
||||
defp dedup_key_from_attrs(attrs) do
|
||||
dedup_key(attrs["station1"], attrs["grid1"], attrs["station2"], attrs["grid2"], attrs["band"])
|
||||
defp prefix_key_from_attrs(attrs) do
|
||||
ImportMatcher.prefix_key(
|
||||
attrs["station1"],
|
||||
attrs["grid1"],
|
||||
attrs["station2"],
|
||||
attrs["grid2"],
|
||||
attrs["band"]
|
||||
)
|
||||
end
|
||||
|
||||
defp dedup_key(s1, g1, s2, g2, band) do
|
||||
a = normalize_callsite(s1, g1)
|
||||
b = normalize_callsite(s2, g2)
|
||||
[c1, c2] = Enum.sort([a, b])
|
||||
{c1, c2, normalize_band(band)}
|
||||
end
|
||||
|
||||
defp normalize_callsite(call, grid) do
|
||||
{upcase_trim(call), upcase_trim(grid)}
|
||||
end
|
||||
|
||||
defp upcase_trim(nil), do: ""
|
||||
defp upcase_trim(value), do: value |> to_string() |> String.trim() |> String.upcase()
|
||||
|
||||
defp normalize_band(%Decimal{} = b), do: Decimal.to_integer(b)
|
||||
defp normalize_band(b) when is_integer(b), do: b
|
||||
defp normalize_band(b) when is_binary(b), do: b |> String.trim() |> String.to_integer()
|
||||
|
||||
defp to_decimal(%Decimal{} = d), do: d
|
||||
defp to_decimal(v) when is_integer(v), do: Decimal.new(v)
|
||||
defp to_decimal(v) when is_binary(v), do: v |> String.trim() |> Decimal.new()
|
||||
|
|
|
|||
171
lib/microwaveprop/radio/import_matcher.ex
Normal file
171
lib/microwaveprop/radio/import_matcher.ex
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
defmodule Microwaveprop.Radio.ImportMatcher do
|
||||
@moduledoc """
|
||||
Pure classification for CSV/ADIF upload rows against existing Contact records.
|
||||
|
||||
`prefix_key/5` builds a sortable key from `{station, 4-char grid prefix, band}`
|
||||
so callers can bulk-lookup candidates from the database. `classify_against_existing/2`
|
||||
compares a single upload row against a single candidate contact and decides
|
||||
whether the upload is a plain duplicate, a grid/mode refinement of the existing
|
||||
record, or an irreconcilable contradiction that must be skipped.
|
||||
"""
|
||||
|
||||
alias Microwaveprop.Radio.Contact
|
||||
|
||||
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
|
||||
|
||||
@type prefix_key ::
|
||||
{{String.t(), String.t()}, {String.t(), String.t()}, integer()} | :invalid
|
||||
|
||||
@type change_map :: %{optional(:grid1) => String.t(), optional(:grid2) => String.t(), optional(:mode) => String.t()}
|
||||
@type classification :: :duplicate | {:refinement, change_map()} | :contradiction
|
||||
|
||||
@spec prefix_key(String.t() | nil, String.t() | nil, String.t() | nil, String.t() | nil, term()) ::
|
||||
prefix_key()
|
||||
def prefix_key(station1, grid1, station2, grid2, band) do
|
||||
with {:ok, s1, g1_prefix} <- build_side(station1, grid1),
|
||||
{:ok, s2, g2_prefix} <- build_side(station2, grid2),
|
||||
{:ok, band_int} <- normalize_band(band) do
|
||||
[a, b] = Enum.sort([{s1, g1_prefix}, {s2, g2_prefix}])
|
||||
{a, b, band_int}
|
||||
else
|
||||
_ -> :invalid
|
||||
end
|
||||
end
|
||||
|
||||
defp build_side(station, grid) do
|
||||
with station_up when is_binary(station_up) <- upcase_trim(station),
|
||||
grid_up when is_binary(grid_up) <- upcase_trim(grid),
|
||||
true <- byte_size(grid_up) >= 4 do
|
||||
{:ok, station_up, binary_part(grid_up, 0, 4)}
|
||||
else
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp upcase_trim(nil), do: nil
|
||||
|
||||
defp upcase_trim(value) when is_binary(value) do
|
||||
trimmed = String.trim(value)
|
||||
if trimmed == "", do: nil, else: String.upcase(trimmed)
|
||||
end
|
||||
|
||||
defp upcase_trim(_), do: nil
|
||||
|
||||
defp normalize_band(%Decimal{} = d) do
|
||||
{:ok, Decimal.to_integer(d)}
|
||||
rescue
|
||||
_ -> :error
|
||||
end
|
||||
|
||||
defp normalize_band(value) when is_integer(value), do: {:ok, value}
|
||||
|
||||
defp normalize_band(value) when is_binary(value) do
|
||||
case value |> String.trim() |> Integer.parse() do
|
||||
{int, ""} -> {:ok, int}
|
||||
_ -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_band(_), do: :error
|
||||
|
||||
@spec classify_against_existing(map(), Contact.t()) :: classification()
|
||||
def classify_against_existing(upload_attrs, %Contact{} = existing) do
|
||||
up_s1 = upcase_trim(upload_attrs["station1"])
|
||||
up_s2 = upcase_trim(upload_attrs["station2"])
|
||||
ex_s1 = upcase_trim(existing.station1)
|
||||
ex_s2 = upcase_trim(existing.station2)
|
||||
|
||||
case resolve_direction(ex_s1, ex_s2, up_s1, up_s2) do
|
||||
:ambiguous -> :duplicate
|
||||
{up_grid1_key, up_grid2_key} -> compare_sides(existing, upload_attrs, up_grid1_key, up_grid2_key)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns which upload keys ("grid1"/"grid2") correspond to existing's side1/side2.
|
||||
# If direction matches as-is: {"grid1", "grid2"}. If swapped: {"grid2", "grid1"}.
|
||||
# `:ambiguous` when callsigns don't pair cleanly (e.g. same callsign both sides).
|
||||
defp resolve_direction(ex_s1, ex_s2, up_s1, up_s2) do
|
||||
if pairable_callsigns?(ex_s1, ex_s2, up_s1, up_s2) do
|
||||
match_direction(ex_s1, ex_s2, up_s1, up_s2)
|
||||
else
|
||||
:ambiguous
|
||||
end
|
||||
end
|
||||
|
||||
defp pairable_callsigns?(ex_s1, ex_s2, up_s1, up_s2) do
|
||||
not is_nil(ex_s1) and not is_nil(ex_s2) and not is_nil(up_s1) and not is_nil(up_s2) and
|
||||
ex_s1 != ex_s2 and up_s1 != up_s2
|
||||
end
|
||||
|
||||
defp match_direction(ex_s1, ex_s2, up_s1, up_s2) do
|
||||
cond do
|
||||
ex_s1 == up_s1 and ex_s2 == up_s2 -> {"grid1", "grid2"}
|
||||
ex_s1 == up_s2 and ex_s2 == up_s1 -> {"grid2", "grid1"}
|
||||
true -> :ambiguous
|
||||
end
|
||||
end
|
||||
|
||||
defp compare_sides(existing, upload_attrs, up_grid1_key, up_grid2_key) do
|
||||
up_g1 = upcase_trim(upload_attrs[up_grid1_key])
|
||||
up_g2 = upcase_trim(upload_attrs[up_grid2_key])
|
||||
ex_g1 = upcase_trim(existing.grid1)
|
||||
ex_g2 = upcase_trim(existing.grid2)
|
||||
|
||||
grid1_result = compare_grid(ex_g1, up_g1)
|
||||
grid2_result = compare_grid(ex_g2, up_g2)
|
||||
mode_result = compare_mode(existing.mode, upload_attrs["mode"])
|
||||
|
||||
combine_results(grid1_result, grid2_result, mode_result)
|
||||
end
|
||||
|
||||
# Each compare_* returns :same | :contradiction | {:refine, new_value}
|
||||
defp compare_grid(nil, _up), do: :same
|
||||
defp compare_grid(_ex, nil), do: :same
|
||||
defp compare_grid(ex, up) when ex == up, do: :same
|
||||
|
||||
defp compare_grid(ex, up) do
|
||||
cond do
|
||||
byte_size(up) > byte_size(ex) and String.starts_with?(up, ex) -> {:refine, up}
|
||||
byte_size(up) < byte_size(ex) and String.starts_with?(ex, up) -> :same
|
||||
true -> :contradiction
|
||||
end
|
||||
end
|
||||
|
||||
defp compare_mode(_ex, nil), do: :same
|
||||
|
||||
defp compare_mode(nil, up) do
|
||||
normalized = upcase_trim(up)
|
||||
|
||||
cond do
|
||||
is_nil(normalized) -> :same
|
||||
normalized in @allowed_modes -> {:refine, normalized}
|
||||
true -> :same
|
||||
end
|
||||
end
|
||||
|
||||
defp compare_mode(ex, up) do
|
||||
up_norm = upcase_trim(up)
|
||||
|
||||
cond do
|
||||
is_nil(up_norm) -> :same
|
||||
String.upcase(ex) == up_norm -> :same
|
||||
true -> :contradiction
|
||||
end
|
||||
end
|
||||
|
||||
defp combine_results(grid1_result, grid2_result, mode_result) do
|
||||
results = [{:grid1, grid1_result}, {:grid2, grid2_result}, {:mode, mode_result}]
|
||||
|
||||
if Enum.any?(results, fn {_k, r} -> r == :contradiction end) do
|
||||
:contradiction
|
||||
else
|
||||
changes =
|
||||
Enum.reduce(results, %{}, fn
|
||||
{key, {:refine, value}}, acc -> Map.put(acc, key, value)
|
||||
_, acc -> acc
|
||||
end)
|
||||
|
||||
if changes == %{}, do: :duplicate, else: {:refinement, changes}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -163,13 +163,15 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
|
||||
def handle_event("confirm_csv", _params, socket) do
|
||||
case socket.assigns.csv_preview do
|
||||
%{valid: valid_rows} when valid_rows != [] ->
|
||||
{:ok, commit_result} = CsvImport.commit(valid_rows)
|
||||
%{valid: valid_rows, refinements: refinements}
|
||||
when valid_rows != [] or refinements != [] ->
|
||||
{:ok, commit_result} =
|
||||
CsvImport.commit(%{valid: valid_rows, refinements: refinements})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(csv_preview: nil, csv_result: commit_result)
|
||||
|> put_flash(:info, "#{length(commit_result.imported)} contacts submitted.")}
|
||||
|> put_flash(:info, commit_flash(commit_result))}
|
||||
|
||||
_ ->
|
||||
{:noreply, put_flash(socket, :error, "Nothing to import.")}
|
||||
|
|
@ -619,7 +621,9 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
valid_count: length(assigns.preview.valid),
|
||||
invalid_count: length(assigns.preview.invalid),
|
||||
duplicate_count: length(assigns.preview.duplicates),
|
||||
valid_sample: Enum.take(assigns.preview.valid, 20)
|
||||
refinement_count: length(assigns.preview.refinements || []),
|
||||
valid_sample: Enum.take(assigns.preview.valid, 20),
|
||||
refinement_sample: Enum.take(assigns.preview.refinements || [], 50)
|
||||
)
|
||||
|
||||
~H"""
|
||||
|
|
@ -632,13 +636,19 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<.summary_card
|
||||
tone="success"
|
||||
label="Valid"
|
||||
value={@valid_count}
|
||||
hint="Will be inserted"
|
||||
/>
|
||||
<.summary_card
|
||||
tone="info"
|
||||
label="Refinements"
|
||||
value={@refinement_count}
|
||||
hint="Will update existing contacts"
|
||||
/>
|
||||
<.summary_card
|
||||
tone="warning"
|
||||
label="Duplicates"
|
||||
|
|
@ -678,6 +688,44 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@refinement_count > 0}>
|
||||
<h3 class="font-semibold mb-2">Refinements ({@refinement_count})</h3>
|
||||
<p class="text-xs opacity-60 mb-2">
|
||||
These rows match an existing contact but add more precise data.
|
||||
Confirming will update the existing contact in place.
|
||||
</p>
|
||||
<div class="overflow-x-auto rounded-box border border-base-300">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-20">Row</th>
|
||||
<th>Station 1</th>
|
||||
<th>Station 2</th>
|
||||
<th>Band</th>
|
||||
<th>Changes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={row <- @refinement_sample}>
|
||||
<td class="font-mono">Row {row.row_num}</td>
|
||||
<td>{row.attrs["station1"]} <span class="opacity-60">{row.attrs["grid1"]}</span></td>
|
||||
<td>{row.attrs["station2"]} <span class="opacity-60">{row.attrs["grid2"]}</span></td>
|
||||
<td class="tabular-nums">{row.attrs["band"]}</td>
|
||||
<td class="text-xs">
|
||||
<div :for={{field, value} <- Enum.sort(Map.to_list(row.changes))}>
|
||||
<span class="opacity-60">{field}:</span>
|
||||
<code class="text-xs">{value}</code>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p :if={@refinement_count > length(@refinement_sample)} class="text-xs opacity-60 mt-1">
|
||||
Showing first {length(@refinement_sample)} of {@refinement_count} refinements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@duplicate_count > 0}>
|
||||
<h3 class="font-semibold mb-2">Duplicates ({@duplicate_count})</h3>
|
||||
<p class="text-xs opacity-60 mb-2">
|
||||
|
|
@ -749,17 +797,15 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
|
||||
<div class="flex flex-wrap gap-2 pt-4 border-t border-base-300">
|
||||
<button
|
||||
:if={@valid_count > 0}
|
||||
:if={@valid_count > 0 or @refinement_count > 0}
|
||||
type="button"
|
||||
class="btn btn-primary btn-lg"
|
||||
phx-click="confirm_csv"
|
||||
phx-disable-with="Importing..."
|
||||
data-confirm={"Import #{@valid_count} contacts?"}
|
||||
data-confirm={confirm_prompt(@valid_count, @refinement_count)}
|
||||
>
|
||||
<.icon name="hero-check" class="w-5 h-5" />
|
||||
Looks good — submit {@valid_count} {if @valid_count == 1,
|
||||
do: "contact",
|
||||
else: "contacts"}
|
||||
{confirm_button_label(@valid_count, @refinement_count)}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" phx-click="cancel_csv">
|
||||
Cancel
|
||||
|
|
@ -782,6 +828,7 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
"success" -> "border-success/30 bg-success/10"
|
||||
"warning" -> "border-warning/30 bg-warning/10"
|
||||
"error" -> "border-error/30 bg-error/10"
|
||||
"info" -> "border-info/30 bg-info/10"
|
||||
_ -> "border-base-300 bg-base-200"
|
||||
end
|
||||
]}>
|
||||
|
|
@ -796,10 +843,44 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
defp duplicate_source(:earlier_in_upload), do: "Earlier row in this upload"
|
||||
defp duplicate_source(_), do: "Duplicate"
|
||||
|
||||
defp commit_flash(%{imported: imported, refined: refined}) do
|
||||
imp = length(imported)
|
||||
ref = length(refined)
|
||||
|
||||
case {imp, ref} do
|
||||
{0, n} -> "#{n} #{pluralize(n, "contact")} refined."
|
||||
{n, 0} -> "#{n} #{pluralize(n, "contact")} submitted."
|
||||
{i, r} -> "#{i} submitted, #{r} refined."
|
||||
end
|
||||
end
|
||||
|
||||
defp pluralize(1, word), do: word
|
||||
defp pluralize(_, word), do: word <> "s"
|
||||
|
||||
defp confirm_button_label(valid, 0) do
|
||||
"Looks good — submit #{valid} #{pluralize(valid, "contact")}"
|
||||
end
|
||||
|
||||
defp confirm_button_label(0, refined) do
|
||||
"Looks good — refine #{refined} existing #{pluralize(refined, "contact")}"
|
||||
end
|
||||
|
||||
defp confirm_button_label(valid, refined) do
|
||||
"Looks good — submit #{valid} and refine #{refined}"
|
||||
end
|
||||
|
||||
defp confirm_prompt(valid, 0), do: "Import #{valid} contacts?"
|
||||
defp confirm_prompt(0, refined), do: "Refine #{refined} existing contacts?"
|
||||
|
||||
defp confirm_prompt(valid, refined) do
|
||||
"Import #{valid} contacts and refine #{refined} existing?"
|
||||
end
|
||||
|
||||
defp csv_results(assigns) do
|
||||
assigns =
|
||||
assign(assigns,
|
||||
imported_count: length(assigns.result.imported),
|
||||
refined_count: length(Map.get(assigns.result, :refined, [])),
|
||||
error_count: length(assigns.result.errors)
|
||||
)
|
||||
|
||||
|
|
@ -818,6 +899,20 @@ defmodule MicrowavepropWeb.SubmitLive do
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@refined_count > 0}
|
||||
class="alert alert-info p-8 text-center"
|
||||
>
|
||||
<.icon name="hero-sparkles" class="w-16 h-16 text-info mx-auto mb-3" />
|
||||
<div class="text-3xl font-bold tabular-nums">
|
||||
{@refined_count} existing {if @refined_count == 1, do: "contact", else: "contacts"} refined
|
||||
</div>
|
||||
<p class="text-sm opacity-70 mt-2">
|
||||
Grid or mode updates have been applied.
|
||||
Enrichment will re-run for any grid changes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div :if={@error_count > 0} class="space-y-2">
|
||||
<div class="alert alert-error">
|
||||
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ defmodule Microwaveprop.Radio.AdifImportTest do
|
|||
assert length(preview.valid) == 1
|
||||
assert preview.invalid == []
|
||||
assert preview.duplicates == []
|
||||
assert preview.refinements == []
|
||||
|
||||
[row] = preview.valid
|
||||
assert row.attrs["station1"] == "W5XD"
|
||||
|
|
@ -174,9 +175,34 @@ defmodule Microwaveprop.Radio.AdifImportTest do
|
|||
|
||||
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 =
|
||||
"<STATION_CALLSIGN:4>W5XD<CALL:4>K5TR<GRIDSQUARE:4>EM00<MY_GRIDSQUARE:8>EM12kp37<FREQ:9>10368.000<MODE:2>CW<QSO_DATE:8>20260328<TIME_ON:6>180500<EOR>"
|
||||
|
||||
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 = """
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -527,6 +527,7 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
|||
assert attrs["station1"] == "W5XD"
|
||||
assert preview.invalid == []
|
||||
assert preview.duplicates == []
|
||||
assert preview.refinements == []
|
||||
assert preview.total_rows == 1
|
||||
assert preview.submitter_email == @submitter
|
||||
assert Repo.aggregate(Contact, :count) == 0
|
||||
|
|
@ -556,6 +557,7 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
|||
|
||||
assert {:ok, second} = CsvImport.preview(csv, @submitter)
|
||||
assert second.valid == []
|
||||
assert second.refinements == []
|
||||
assert [%{row_num: 2, reason: :existing_contact}] = second.duplicates
|
||||
end
|
||||
|
||||
|
|
@ -631,6 +633,107 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
|||
|
||||
assert {:ok, preview} = CsvImport.preview(csv, @submitter)
|
||||
assert length(preview.valid) == 2
|
||||
assert preview.refinements == []
|
||||
end
|
||||
|
||||
test "upload with longer grid than existing routes to refinements" do
|
||||
csv = @valid_header <> "\n" <> @valid_row
|
||||
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
||||
{:ok, %{imported: [existing]}} = CsvImport.commit(preview.valid)
|
||||
|
||||
refined_csv =
|
||||
@valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z"
|
||||
|
||||
assert {:ok, p2} = CsvImport.preview(refined_csv, @submitter)
|
||||
assert p2.valid == []
|
||||
assert p2.duplicates == []
|
||||
assert [ref] = p2.refinements
|
||||
assert ref.row_num == 2
|
||||
assert ref.existing_id == existing.id
|
||||
assert ref.changes == %{grid1: "EM12KP37"}
|
||||
end
|
||||
|
||||
test "direction-swapped refinement maps changes to existing orientation" do
|
||||
csv = @valid_header <> "\n" <> @valid_row
|
||||
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
||||
{:ok, %{imported: [existing]}} = CsvImport.commit(preview.valid)
|
||||
|
||||
# Upload as K5TR's side with more-precise grids
|
||||
swapped_csv =
|
||||
@valid_header <> "\n" <> "K5TR,W5XD,EM00bb,EM12aa,10000,CW,2026-03-28T18:05:00Z"
|
||||
|
||||
assert {:ok, p2} = CsvImport.preview(swapped_csv, @submitter)
|
||||
assert p2.valid == []
|
||||
assert [ref] = p2.refinements
|
||||
assert ref.existing_id == existing.id
|
||||
assert ref.changes == %{grid1: "EM12AA", grid2: "EM00BB"}
|
||||
end
|
||||
|
||||
test "upload with mode filling in missing mode routes to refinements" do
|
||||
{:ok, existing} =
|
||||
Microwaveprop.Radio.create_contact(%{
|
||||
station1: "W5XD",
|
||||
station2: "K5TR",
|
||||
grid1: "EM12",
|
||||
grid2: "EM00",
|
||||
band: "10000",
|
||||
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||
submitter_email: @submitter
|
||||
})
|
||||
|
||||
refined_csv = @valid_header <> "\n" <> @valid_row
|
||||
|
||||
assert {:ok, p2} = CsvImport.preview(refined_csv, @submitter)
|
||||
assert p2.valid == []
|
||||
assert [ref] = p2.refinements
|
||||
assert ref.existing_id == existing.id
|
||||
assert ref.changes == %{mode: "CW"}
|
||||
end
|
||||
|
||||
test "contradictory grid (same 4-char prefix, different 6th char) is a duplicate not refinement" do
|
||||
csv =
|
||||
@valid_header <> "\n" <> "W5XD,K5TR,EM12kp,EM00,10000,CW,2026-03-28T18:00:00Z"
|
||||
|
||||
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
||||
{:ok, %{imported: [_existing]}} = CsvImport.commit(preview.valid)
|
||||
|
||||
contradictory_csv =
|
||||
@valid_header <> "\n" <> "W5XD,K5TR,EM12qq,EM00,10000,CW,2026-03-28T18:05:00Z"
|
||||
|
||||
assert {:ok, p2} = CsvImport.preview(contradictory_csv, @submitter)
|
||||
assert p2.valid == []
|
||||
assert p2.refinements == []
|
||||
assert [%{reason: :existing_contact}] = p2.duplicates
|
||||
end
|
||||
|
||||
test "contradictory mode is a duplicate not refinement" do
|
||||
csv = @valid_header <> "\n" <> @valid_row
|
||||
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
||||
{:ok, %{imported: [_]}} = CsvImport.commit(preview.valid)
|
||||
|
||||
mismatched_csv =
|
||||
@valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,SSB,2026-03-28T18:05:00Z"
|
||||
|
||||
assert {:ok, p2} = CsvImport.preview(mismatched_csv, @submitter)
|
||||
assert p2.valid == []
|
||||
assert p2.refinements == []
|
||||
assert [%{reason: :existing_contact}] = p2.duplicates
|
||||
end
|
||||
|
||||
test "rover with different 4-char prefix still classifies as valid" do
|
||||
csv =
|
||||
@valid_header <> "\n" <> "W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"
|
||||
|
||||
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
||||
{:ok, %{imported: [_]}} = CsvImport.commit(preview.valid)
|
||||
|
||||
rover_csv =
|
||||
@valid_header <> "\n" <> "W5XD,K5TR,EM13,EM00,10000,CW,2026-03-28T18:15:00Z"
|
||||
|
||||
assert {:ok, p2} = CsvImport.preview(rover_csv, @submitter)
|
||||
assert length(p2.valid) == 1
|
||||
assert p2.refinements == []
|
||||
assert p2.duplicates == []
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -656,5 +759,108 @@ defmodule Microwaveprop.Radio.CsvImportTest do
|
|||
test "commit of [] is a no-op" do
|
||||
assert {:ok, %{imported: [], errors: []}} = CsvImport.commit([])
|
||||
end
|
||||
|
||||
test "commit accepts map with :valid and :refinements and applies both" do
|
||||
csv = @valid_header <> "\n" <> @valid_row
|
||||
{:ok, p1} = CsvImport.preview(csv, @submitter)
|
||||
{:ok, %{imported: [existing]}} = CsvImport.commit(p1.valid)
|
||||
|
||||
refined_csv =
|
||||
@valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z"
|
||||
|
||||
{:ok, p2} = CsvImport.preview(refined_csv, @submitter)
|
||||
assert [_ref] = p2.refinements
|
||||
|
||||
assert {:ok, %{imported: [], refined: [refined], errors: []}} =
|
||||
CsvImport.commit(%{valid: p2.valid, refinements: p2.refinements})
|
||||
|
||||
assert refined.id == existing.id
|
||||
assert refined.grid1 == "EM12KP37"
|
||||
end
|
||||
|
||||
test "commit with grid refinement re-enqueues enrichment" do
|
||||
csv = @valid_header <> "\n" <> @valid_row
|
||||
{:ok, p1} = CsvImport.preview(csv, @submitter)
|
||||
{:ok, %{imported: [existing]}} = CsvImport.commit(p1.valid)
|
||||
|
||||
# Drive enrichment statuses to :complete so a reset is observable.
|
||||
Repo.update!(
|
||||
Ecto.Changeset.change(existing, %{
|
||||
hrrr_status: :complete,
|
||||
weather_status: :complete,
|
||||
terrain_status: :complete,
|
||||
iemre_status: :complete
|
||||
})
|
||||
)
|
||||
|
||||
refined_csv =
|
||||
@valid_header <> "\n" <> "W5XD,K5TR,EM12kp37,EM00,10000,CW,2026-03-28T18:05:00Z"
|
||||
|
||||
{:ok, p2} = CsvImport.preview(refined_csv, @submitter)
|
||||
|
||||
assert {:ok, %{refined: [refined]}} =
|
||||
CsvImport.commit(%{valid: p2.valid, refinements: p2.refinements})
|
||||
|
||||
# After apply_contact_refinement + re-enqueue, HRRR enrichment ran
|
||||
# again and (because the HRRR stub returns 404) lands at :unavailable.
|
||||
# Weather/iemre/terrain may resolve back to :complete if no jobs could
|
||||
# be built, so HRRR is the definitive "was it re-enqueued" signal.
|
||||
reloaded = Repo.reload!(refined)
|
||||
refute reloaded.hrrr_status == :complete
|
||||
end
|
||||
|
||||
test "commit with mode-only refinement does not re-enqueue enrichment" do
|
||||
{:ok, existing} =
|
||||
Microwaveprop.Radio.create_contact(%{
|
||||
station1: "W5XD",
|
||||
station2: "K5TR",
|
||||
grid1: "EM12",
|
||||
grid2: "EM00",
|
||||
band: "10000",
|
||||
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||
submitter_email: @submitter
|
||||
})
|
||||
|
||||
Repo.update!(
|
||||
Ecto.Changeset.change(existing, %{
|
||||
hrrr_status: :complete,
|
||||
weather_status: :complete,
|
||||
terrain_status: :complete,
|
||||
iemre_status: :complete
|
||||
})
|
||||
)
|
||||
|
||||
refined_csv = @valid_header <> "\n" <> @valid_row
|
||||
{:ok, p} = CsvImport.preview(refined_csv, @submitter)
|
||||
assert [%{changes: %{mode: "CW"}}] = p.refinements
|
||||
|
||||
assert {:ok, %{refined: [refined]}} =
|
||||
CsvImport.commit(%{valid: p.valid, refinements: p.refinements})
|
||||
|
||||
reloaded = Repo.reload!(refined)
|
||||
assert reloaded.mode == "CW"
|
||||
assert reloaded.hrrr_status == :complete
|
||||
assert reloaded.weather_status == :complete
|
||||
assert reloaded.terrain_status == :complete
|
||||
assert reloaded.iemre_status == :complete
|
||||
end
|
||||
|
||||
test "commit(list) backward compat still works" do
|
||||
csv =
|
||||
Enum.join(
|
||||
[
|
||||
@valid_header,
|
||||
"W5XD,K5TR,EM12,EM00,10000,CW,2026-03-28T18:00:00Z"
|
||||
],
|
||||
"\n"
|
||||
)
|
||||
|
||||
{:ok, preview} = CsvImport.preview(csv, @submitter)
|
||||
assert {:ok, result} = CsvImport.commit(preview.valid)
|
||||
assert [_contact] = result.imported
|
||||
assert result.errors == []
|
||||
# The legacy list form still ships a :refined key with an empty list.
|
||||
assert result.refined == []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
189
test/microwaveprop/radio/import_matcher_test.exs
Normal file
189
test/microwaveprop/radio/import_matcher_test.exs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
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
|
||||
152
test/microwaveprop/radio_refinement_test.exs
Normal file
152
test/microwaveprop/radio_refinement_test.exs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
defmodule Microwaveprop.RadioRefinementTest do
|
||||
use Microwaveprop.DataCase, async: true
|
||||
|
||||
alias Microwaveprop.Radio
|
||||
alias Microwaveprop.Radio.Contact
|
||||
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
|
||||
|
||||
defp insert_contact(attrs \\ %{}) do
|
||||
base = %{
|
||||
station1: "W5XD",
|
||||
station2: "K5TR",
|
||||
grid1: "EM12",
|
||||
grid2: "EM00",
|
||||
band: "10000",
|
||||
mode: "CW",
|
||||
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||
submitter_email: "test@example.com"
|
||||
}
|
||||
|
||||
{:ok, contact} = Radio.create_contact(Map.merge(base, attrs))
|
||||
contact
|
||||
end
|
||||
|
||||
defp mark_all_enrichment_complete(%Contact{} = contact) do
|
||||
contact
|
||||
|> Ecto.Changeset.change(%{
|
||||
hrrr_status: :complete,
|
||||
weather_status: :complete,
|
||||
terrain_status: :complete,
|
||||
iemre_status: :complete
|
||||
})
|
||||
|> Repo.update!()
|
||||
end
|
||||
|
||||
describe "apply_contact_refinement/2" do
|
||||
test "refining grid1 updates grid1, pos1, distance, and resets all enrichment statuses" do
|
||||
contact = mark_all_enrichment_complete(insert_contact())
|
||||
original_grid2 = contact.grid2
|
||||
original_pos2 = contact.pos2
|
||||
|
||||
assert {:ok, refined} = Radio.apply_contact_refinement(contact, %{grid1: "EM12KP"})
|
||||
|
||||
assert refined.grid1 == "EM12KP"
|
||||
assert refined.grid2 == original_grid2
|
||||
assert refined.pos2 == original_pos2
|
||||
refute refined.pos1 == contact.pos1
|
||||
assert refined.pos1["lat"] != contact.pos1["lat"] or refined.pos1["lon"] != contact.pos1["lon"]
|
||||
assert refined.distance_km
|
||||
assert refined.hrrr_status == :pending
|
||||
assert refined.weather_status == :pending
|
||||
assert refined.terrain_status == :pending
|
||||
assert refined.iemre_status == :pending
|
||||
end
|
||||
|
||||
test "refining grid2 updates grid2, pos2, distance, and resets all enrichment statuses" do
|
||||
contact = mark_all_enrichment_complete(insert_contact())
|
||||
original_grid1 = contact.grid1
|
||||
original_pos1 = contact.pos1
|
||||
|
||||
assert {:ok, refined} = Radio.apply_contact_refinement(contact, %{grid2: "EM00CD"})
|
||||
|
||||
assert refined.grid1 == original_grid1
|
||||
assert refined.pos1 == original_pos1
|
||||
assert refined.grid2 == "EM00CD"
|
||||
refute refined.pos2 == contact.pos2
|
||||
assert refined.hrrr_status == :pending
|
||||
assert refined.weather_status == :pending
|
||||
assert refined.terrain_status == :pending
|
||||
assert refined.iemre_status == :pending
|
||||
end
|
||||
|
||||
test "refining both grids updates both + recomputes distance" do
|
||||
contact = mark_all_enrichment_complete(insert_contact())
|
||||
original_distance = contact.distance_km
|
||||
|
||||
assert {:ok, refined} =
|
||||
Radio.apply_contact_refinement(contact, %{grid1: "EM12KP", grid2: "EM00CD"})
|
||||
|
||||
assert refined.grid1 == "EM12KP"
|
||||
assert refined.grid2 == "EM00CD"
|
||||
assert refined.distance_km
|
||||
# Distances remain similar (grids refined within same squares) but pos values change.
|
||||
assert refined.pos1["lat"] != contact.pos1["lat"] or refined.pos1["lon"] != contact.pos1["lon"]
|
||||
assert refined.pos2["lat"] != contact.pos2["lat"] or refined.pos2["lon"] != contact.pos2["lon"]
|
||||
assert refined.distance_km != original_distance or refined.distance_km == original_distance
|
||||
end
|
||||
|
||||
test "refining only :mode updates mode without touching pos/distance or statuses" do
|
||||
contact = %{mode: nil} |> insert_contact() |> mark_all_enrichment_complete()
|
||||
original_pos1 = contact.pos1
|
||||
original_pos2 = contact.pos2
|
||||
original_distance = contact.distance_km
|
||||
|
||||
assert {:ok, refined} = Radio.apply_contact_refinement(contact, %{mode: "CW"})
|
||||
|
||||
assert refined.mode == "CW"
|
||||
assert refined.pos1 == original_pos1
|
||||
assert refined.pos2 == original_pos2
|
||||
assert refined.distance_km == original_distance
|
||||
assert refined.hrrr_status == :complete
|
||||
assert refined.weather_status == :complete
|
||||
assert refined.terrain_status == :complete
|
||||
assert refined.iemre_status == :complete
|
||||
end
|
||||
|
||||
test "empty changes map returns the contact unchanged" do
|
||||
contact = mark_all_enrichment_complete(insert_contact())
|
||||
assert {:ok, unchanged} = Radio.apply_contact_refinement(contact, %{})
|
||||
assert unchanged.id == contact.id
|
||||
assert unchanged.grid1 == contact.grid1
|
||||
assert unchanged.grid2 == contact.grid2
|
||||
assert unchanged.mode == contact.mode
|
||||
assert unchanged.hrrr_status == :complete
|
||||
end
|
||||
|
||||
test "invalid Maidenhead grid returns error and does not mutate the DB row" do
|
||||
contact = mark_all_enrichment_complete(insert_contact())
|
||||
|
||||
assert {:error, %Ecto.Changeset{} = changeset} =
|
||||
Radio.apply_contact_refinement(contact, %{grid1: "ZZZZ"})
|
||||
|
||||
refute changeset.valid?
|
||||
|
||||
reloaded = Repo.reload!(contact)
|
||||
assert reloaded.grid1 == contact.grid1
|
||||
assert reloaded.hrrr_status == :complete
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -147,6 +147,58 @@ defmodule MicrowavepropWeb.SubmitLiveTest do
|
|||
refute html =~ "Looks good"
|
||||
end
|
||||
|
||||
test "preview shows refinements when upload extends an existing grid", %{conn: conn} do
|
||||
{:ok, existing} =
|
||||
Microwaveprop.Radio.create_contact(%{
|
||||
"station1" => "W5XD",
|
||||
"station2" => "K5TR",
|
||||
"grid1" => "EM12",
|
||||
"grid2" => "EM00",
|
||||
"band" => "10000",
|
||||
"mode" => "CW",
|
||||
"qso_timestamp" => "2026-03-28T18:00:00Z",
|
||||
"submitter_email" => "seed@example.com"
|
||||
})
|
||||
|
||||
{:ok, lv, _html} = live(conn, ~p"/submit")
|
||||
|
||||
lv
|
||||
|> element("[phx-value-tab=csv]")
|
||||
|> render_click()
|
||||
|
||||
csv_content =
|
||||
"station1,station2,grid1,grid2,band,mode,qso_timestamp\n" <>
|
||||
"W5XD,K5TR,EM12KP37,EM00CD22,10000,CW,2026-03-28T18:10:00Z\n"
|
||||
|
||||
csv_input =
|
||||
file_input(lv, "#csv-upload-form", :csv, [
|
||||
%{name: "contacts.csv", content: csv_content, type: "text/csv"}
|
||||
])
|
||||
|
||||
render_upload(csv_input, "contacts.csv")
|
||||
|
||||
html =
|
||||
lv
|
||||
|> form("#csv-upload-form", %{submitter_email: "test@example.com"})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "Refinements"
|
||||
assert html =~ "EM12KP37"
|
||||
assert html =~ "refine 1 existing"
|
||||
|
||||
confirm_html =
|
||||
lv
|
||||
|> element("button[phx-click=confirm_csv]")
|
||||
|> render_click()
|
||||
|
||||
assert confirm_html =~ "1 existing contact refined"
|
||||
assert Repo.aggregate(Contact, :count) == 1
|
||||
|
||||
refreshed = Repo.reload(existing)
|
||||
assert refreshed.grid1 == "EM12KP37"
|
||||
assert refreshed.grid2 == "EM00CD22"
|
||||
end
|
||||
|
||||
test "shows errors for invalid rows and still offers the confirm button", %{conn: conn} do
|
||||
{:ok, lv, _html} = live(conn, ~p"/submit")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue