Add /submit route for user QSO submission
Maidenhead grid module converts grid squares to lat/lon coordinates. Submission form validates grids, bands, modes, and email, computes positions and distance, then triggers the weather/HRRR/terrain processing pipeline via Oban.
This commit is contained in:
parent
d67ced2a0c
commit
7d0978f7d5
12 changed files with 587 additions and 0 deletions
|
|
@ -3,6 +3,7 @@ defmodule Microwaveprop.Radio do
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
alias Microwaveprop.Radio.Qso
|
alias Microwaveprop.Radio.Qso
|
||||||
alias Microwaveprop.Repo
|
alias Microwaveprop.Repo
|
||||||
|
|
||||||
|
|
@ -124,4 +125,31 @@ defmodule Microwaveprop.Radio do
|
||||||
def get_qso!(id) do
|
def get_qso!(id) do
|
||||||
Repo.get!(Qso, id)
|
Repo.get!(Qso, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def change_qso(qso, attrs \\ %{}) do
|
||||||
|
Qso.submission_changeset(qso, attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_qso(attrs) do
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
|
||||||
|
if changeset.valid? do
|
||||||
|
grid1 = Ecto.Changeset.get_change(changeset, :grid1)
|
||||||
|
grid2 = Ecto.Changeset.get_change(changeset, :grid2)
|
||||||
|
|
||||||
|
{:ok, {lat1, lon1}} = Maidenhead.to_latlon(grid1)
|
||||||
|
{:ok, {lat2, lon2}} = Maidenhead.to_latlon(grid2)
|
||||||
|
|
||||||
|
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
|
||||||
|
|
||||||
|
changeset
|
||||||
|
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|
||||||
|
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|
||||||
|
|> Ecto.Changeset.put_change(:distance_km, distance)
|
||||||
|
|> Ecto.Changeset.put_change(:user_submitted, true)
|
||||||
|
|> Repo.insert()
|
||||||
|
else
|
||||||
|
{:error, %{changeset | action: :insert}}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
41
lib/microwaveprop/radio/maidenhead.ex
Normal file
41
lib/microwaveprop/radio/maidenhead.ex
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
defmodule Microwaveprop.Radio.Maidenhead do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@spec valid?(any()) :: boolean()
|
||||||
|
def valid?(nil), do: false
|
||||||
|
|
||||||
|
def valid?(grid) when is_binary(grid) do
|
||||||
|
String.match?(grid, ~r/^[A-Ra-r]{2}[0-9]{2}$/) or
|
||||||
|
String.match?(grid, ~r/^[A-Ra-r]{2}[0-9]{2}[A-Xa-x]{2}$/)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid?(_), do: false
|
||||||
|
|
||||||
|
@spec to_latlon(any()) :: {:ok, {float(), float()}} | :error
|
||||||
|
def to_latlon(nil), do: :error
|
||||||
|
|
||||||
|
def to_latlon(grid) when is_binary(grid) do
|
||||||
|
grid = String.upcase(grid)
|
||||||
|
|
||||||
|
if valid?(grid) do
|
||||||
|
<<f1, f2, s1, s2>> <> rest = grid
|
||||||
|
|
||||||
|
lon = (f1 - ?A) * 20 + (s1 - ?0) * 2 - 180
|
||||||
|
lat = (f2 - ?A) * 10 + (s2 - ?0) * 1 - 90
|
||||||
|
|
||||||
|
case rest do
|
||||||
|
<<ss1, ss2>> ->
|
||||||
|
lon = lon + (ss1 - ?A) * (5 / 60) + 5 / 120
|
||||||
|
lat = lat + (ss2 - ?A) * (2.5 / 60) + 2.5 / 120
|
||||||
|
{:ok, {lat, lon}}
|
||||||
|
|
||||||
|
"" ->
|
||||||
|
{:ok, {lat + 0.5, lon + 1.0}}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
:error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_latlon(_), do: :error
|
||||||
|
end
|
||||||
|
|
@ -4,6 +4,8 @@ defmodule Microwaveprop.Radio.Qso do
|
||||||
|
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
|
||||||
@primary_key {:id, :binary_id, autogenerate: true}
|
@primary_key {:id, :binary_id, autogenerate: true}
|
||||||
@foreign_key_type :binary_id
|
@foreign_key_type :binary_id
|
||||||
|
|
||||||
|
|
@ -21,6 +23,8 @@ defmodule Microwaveprop.Radio.Qso do
|
||||||
field :weather_queued, :boolean, default: false
|
field :weather_queued, :boolean, default: false
|
||||||
field :hrrr_queued, :boolean, default: false
|
field :hrrr_queued, :boolean, default: false
|
||||||
field :terrain_queued, :boolean, default: false
|
field :terrain_queued, :boolean, default: false
|
||||||
|
field :user_submitted, :boolean, default: false
|
||||||
|
field :submitter_email, :string
|
||||||
|
|
||||||
timestamps(type: :utc_datetime)
|
timestamps(type: :utc_datetime)
|
||||||
end
|
end
|
||||||
|
|
@ -33,4 +37,29 @@ defmodule Microwaveprop.Radio.Qso do
|
||||||
|> cast(attrs, @required_fields ++ @optional_fields)
|
|> cast(attrs, @required_fields ++ @optional_fields)
|
||||||
|> validate_required(@required_fields)
|
|> validate_required(@required_fields)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email)a
|
||||||
|
@allowed_modes ~w(CW SSB FM FT8 FT4)
|
||||||
|
@allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 75000), &Decimal.new/1)
|
||||||
|
|
||||||
|
def submission_changeset(qso, attrs) do
|
||||||
|
qso
|
||||||
|
|> cast(attrs, @submission_fields)
|
||||||
|
|> validate_required(@submission_fields)
|
||||||
|
|> validate_grid(:grid1)
|
||||||
|
|> validate_grid(:grid2)
|
||||||
|
|> validate_inclusion(:mode, @allowed_modes)
|
||||||
|
|> validate_inclusion(:band, @allowed_bands)
|
||||||
|
|> validate_format(:submitter_email, ~r/@/)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp validate_grid(changeset, field) do
|
||||||
|
validate_change(changeset, field, fn _, value ->
|
||||||
|
if Maidenhead.valid?(value) do
|
||||||
|
[]
|
||||||
|
else
|
||||||
|
[{field, "is not a valid Maidenhead grid square"}]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ defmodule MicrowavepropWeb.QsoLive.Index do
|
||||||
<.header>
|
<.header>
|
||||||
QSOs
|
QSOs
|
||||||
<:subtitle>{@total_entries} contacts</:subtitle>
|
<:subtitle>{@total_entries} contacts</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.link navigate={~p"/submit"} class="btn btn-sm btn-primary">
|
||||||
|
<.icon name="hero-plus" class="w-4 h-4" /> Submit QSO
|
||||||
|
</.link>
|
||||||
|
</:actions>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.table
|
<.table
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,12 @@ defmodule MicrowavepropWeb.QsoLive.Show do
|
||||||
<:item title="Grid 2">{@qso.grid2 || "—"}</:item>
|
<:item title="Grid 2">{@qso.grid2 || "—"}</:item>
|
||||||
<:item title="Distance">{@qso.distance_km || "—"} km</:item>
|
<:item title="Distance">{@qso.distance_km || "—"} km</:item>
|
||||||
<:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:item>
|
<:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")}</:item>
|
||||||
|
<:item :if={@qso.user_submitted} title="Source">
|
||||||
|
<span class="badge badge-info badge-sm">User Submitted</span>
|
||||||
|
</:item>
|
||||||
|
<:item :if={@qso.user_submitted && @qso.submitter_email} title="Submitter">
|
||||||
|
{@qso.submitter_email}
|
||||||
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
|
|
||||||
<div class="divider" />
|
<div class="divider" />
|
||||||
|
|
|
||||||
125
lib/microwaveprop_web/live/submit_live.ex
Normal file
125
lib/microwaveprop_web/live/submit_live.ex
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
defmodule MicrowavepropWeb.SubmitLive do
|
||||||
|
@moduledoc false
|
||||||
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio
|
||||||
|
alias Microwaveprop.Radio.Qso
|
||||||
|
alias Microwaveprop.Workers.QsoWeatherEnqueueWorker
|
||||||
|
|
||||||
|
@band_options [
|
||||||
|
{"1296 MHz", "1296"},
|
||||||
|
{"2304 MHz", "2304"},
|
||||||
|
{"3456 MHz", "3456"},
|
||||||
|
{"5760 MHz", "5760"},
|
||||||
|
{"10 GHz", "10000"},
|
||||||
|
{"24 GHz", "24000"},
|
||||||
|
{"47 GHz", "47000"},
|
||||||
|
{"76 GHz", "75000"}
|
||||||
|
]
|
||||||
|
|
||||||
|
@mode_options ~w(CW SSB FM FT8 FT4)
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
changeset = Radio.change_qso(%Qso{})
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
assign(socket,
|
||||||
|
page_title: "Submit QSO",
|
||||||
|
form: to_form(changeset),
|
||||||
|
band_options: @band_options,
|
||||||
|
mode_options: @mode_options
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("validate", %{"qso" => qso_params}, socket) do
|
||||||
|
changeset =
|
||||||
|
%Qso{}
|
||||||
|
|> Radio.change_qso(qso_params)
|
||||||
|
|> Map.put(:action, :validate)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, form: to_form(changeset))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("save", %{"qso" => qso_params}, socket) do
|
||||||
|
case Radio.create_qso(qso_params) do
|
||||||
|
{:ok, qso} ->
|
||||||
|
Oban.insert(QsoWeatherEnqueueWorker.new(%{}))
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> put_flash(:info, "QSO submitted successfully!")
|
||||||
|
|> push_navigate(to: ~p"/qsos/#{qso.id}")}
|
||||||
|
|
||||||
|
{:error, changeset} ->
|
||||||
|
{:noreply, assign(socket, form: to_form(changeset))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<.header>
|
||||||
|
Submit QSO
|
||||||
|
<:subtitle>Submit a microwave QSO for propagation analysis</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<.form for={@form} id="qso-form" phx-change="validate" phx-submit="save" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<.input field={@form[:station1]} type="text" label="Station 1" placeholder="W5XD" />
|
||||||
|
<.input
|
||||||
|
field={@form[:grid1]}
|
||||||
|
type="text"
|
||||||
|
label="Grid 1"
|
||||||
|
placeholder="EM12kp"
|
||||||
|
phx-debounce="blur"
|
||||||
|
/>
|
||||||
|
<.input field={@form[:station2]} type="text" label="Station 2" placeholder="K5TR" />
|
||||||
|
<.input
|
||||||
|
field={@form[:grid2]}
|
||||||
|
type="text"
|
||||||
|
label="Grid 2"
|
||||||
|
placeholder="EM00cd"
|
||||||
|
phx-debounce="blur"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-base-content/60 -mt-2">
|
||||||
|
Be as specific as possible with grid squares (6 characters preferred, e.g. EM12kp).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<.input
|
||||||
|
field={@form[:band]}
|
||||||
|
type="select"
|
||||||
|
label="Band"
|
||||||
|
prompt="Select band"
|
||||||
|
options={@band_options}
|
||||||
|
/>
|
||||||
|
<.input
|
||||||
|
field={@form[:mode]}
|
||||||
|
type="select"
|
||||||
|
label="Mode"
|
||||||
|
prompt="Select mode"
|
||||||
|
options={@mode_options}
|
||||||
|
/>
|
||||||
|
<.input field={@form[:qso_timestamp]} type="datetime-local" label="Timestamp (UTC)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.input
|
||||||
|
field={@form[:submitter_email]}
|
||||||
|
type="email"
|
||||||
|
label="Your Email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<.button phx-disable-with="Submitting..." class="btn-primary">Submit QSO</.button>
|
||||||
|
</div>
|
||||||
|
</.form>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -19,6 +19,7 @@ defmodule MicrowavepropWeb.Router do
|
||||||
|
|
||||||
get "/", PageController, :home
|
get "/", PageController, :home
|
||||||
|
|
||||||
|
live "/submit", SubmitLive
|
||||||
live "/qsos", QsoLive.Index
|
live "/qsos", QsoLive.Index
|
||||||
live "/qsos/:id", QsoLive.Show
|
live "/qsos/:id", QsoLive.Show
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
defmodule Microwaveprop.Repo.Migrations.AddSubmissionFieldsToQsos do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
alter table(:qsos) do
|
||||||
|
add :user_submitted, :boolean, default: false, null: false
|
||||||
|
add :submitter_email, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
94
test/microwaveprop/radio/maidenhead_test.exs
Normal file
94
test/microwaveprop/radio/maidenhead_test.exs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
defmodule Microwaveprop.Radio.MaidenheadTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Maidenhead
|
||||||
|
|
||||||
|
describe "valid?/1" do
|
||||||
|
test "accepts valid 4-char grid" do
|
||||||
|
assert Maidenhead.valid?("EM12")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts valid 6-char grid" do
|
||||||
|
assert Maidenhead.valid?("EM12ab")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is case-insensitive" do
|
||||||
|
assert Maidenhead.valid?("em12")
|
||||||
|
assert Maidenhead.valid?("EM12AB")
|
||||||
|
assert Maidenhead.valid?("em12ab")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects field chars outside A-R" do
|
||||||
|
refute Maidenhead.valid?("SZ12")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects subsquare chars outside a-x" do
|
||||||
|
refute Maidenhead.valid?("EM12yz")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects wrong length" do
|
||||||
|
refute Maidenhead.valid?("EM1")
|
||||||
|
refute Maidenhead.valid?("EM123")
|
||||||
|
refute Maidenhead.valid?("EM12abc")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects non-digit in square positions" do
|
||||||
|
refute Maidenhead.valid?("EMAB")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects empty string" do
|
||||||
|
refute Maidenhead.valid?("")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects nil" do
|
||||||
|
refute Maidenhead.valid?(nil)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "to_latlon/1" do
|
||||||
|
test "returns center of 4-char grid FN31" do
|
||||||
|
# FN31: F=5, N=13 -> lon = 5*20 - 180 + 3*2 + 1 = -73, lat = 13*10 - 90 + 1*1 + 0.5 = 41.5
|
||||||
|
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("FN31")
|
||||||
|
assert_in_delta lat, 41.5, 0.01
|
||||||
|
assert_in_delta lon, -73.0, 0.01
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns center of 6-char grid FN31pr" do
|
||||||
|
# FN31pr: field F=5,N=13; square 3,1; subsquare p=15,r=17
|
||||||
|
# lon = 5*20 - 180 + 3*2 + 15*(5/60) + (5/120) = -100 + 6 + 1.25 + 0.0417 = -72.7083
|
||||||
|
# lat = 13*10 - 90 + 1*1 + 17*(2.5/60) + (2.5/120) = 40 + 1 + 0.7083 + 0.02083 = 41.7292
|
||||||
|
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("FN31pr")
|
||||||
|
assert_in_delta lat, 41.7292, 0.01
|
||||||
|
assert_in_delta lon, -72.7083, 0.01
|
||||||
|
end
|
||||||
|
|
||||||
|
test "is case-insensitive" do
|
||||||
|
assert {:ok, {lat1, lon1}} = Maidenhead.to_latlon("FN31")
|
||||||
|
assert {:ok, {lat2, lon2}} = Maidenhead.to_latlon("fn31")
|
||||||
|
assert lat1 == lat2
|
||||||
|
assert lon1 == lon2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :error for invalid grid" do
|
||||||
|
assert :error = Maidenhead.to_latlon("ZZ99")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns :error for nil" do
|
||||||
|
assert :error = Maidenhead.to_latlon(nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "AA00 returns southwest corner center" do
|
||||||
|
# lon = 0*20 - 180 + 0*2 + 1 = -179, lat = 0*10 - 90 + 0*1 + 0.5 = -89.5
|
||||||
|
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("AA00")
|
||||||
|
assert_in_delta lat, -89.5, 0.01
|
||||||
|
assert_in_delta lon, -179.0, 0.01
|
||||||
|
end
|
||||||
|
|
||||||
|
test "RR99 returns northeast corner center" do
|
||||||
|
# lon = 17*20 - 180 + 9*2 + 1 = 179, lat = 17*10 - 90 + 9*1 + 0.5 = 89.5
|
||||||
|
assert {:ok, {lat, lon}} = Maidenhead.to_latlon("RR99")
|
||||||
|
assert_in_delta lat, 89.5, 0.01
|
||||||
|
assert_in_delta lon, 179.0, 0.01
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
95
test/microwaveprop/radio/qso_submission_test.exs
Normal file
95
test/microwaveprop/radio/qso_submission_test.exs
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
defmodule Microwaveprop.Radio.QsoSubmissionTest do
|
||||||
|
use Microwaveprop.DataCase, async: true
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Qso
|
||||||
|
|
||||||
|
@valid_attrs %{
|
||||||
|
station1: "W5XD",
|
||||||
|
station2: "K5TR",
|
||||||
|
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||||
|
mode: "CW",
|
||||||
|
band: "1296",
|
||||||
|
grid1: "EM12",
|
||||||
|
grid2: "EM00",
|
||||||
|
submitter_email: "test@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
describe "submission_changeset/2" do
|
||||||
|
test "valid attrs produce a valid changeset" do
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, @valid_attrs)
|
||||||
|
assert changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "requires station1, station2, qso_timestamp, mode, band, grid1, grid2, submitter_email" do
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, %{})
|
||||||
|
errors = errors_on(changeset)
|
||||||
|
|
||||||
|
assert errors[:station1]
|
||||||
|
assert errors[:station2]
|
||||||
|
assert errors[:qso_timestamp]
|
||||||
|
assert errors[:mode]
|
||||||
|
assert errors[:band]
|
||||||
|
assert errors[:grid1]
|
||||||
|
assert errors[:grid2]
|
||||||
|
assert errors[:submitter_email]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid grid1" do
|
||||||
|
attrs = Map.put(@valid_attrs, :grid1, "ZZ99")
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid grid2" do
|
||||||
|
attrs = Map.put(@valid_attrs, :grid2, "ZZ99")
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
assert "is not a valid Maidenhead grid square" in errors_on(changeset).grid2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts valid 6-char grids" do
|
||||||
|
attrs = %{@valid_attrs | grid1: "EM12ab", grid2: "EM00cd"}
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
assert changeset.valid?
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid mode" do
|
||||||
|
attrs = Map.put(@valid_attrs, :mode, "RTTY")
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
assert errors_on(changeset).mode
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts valid modes" do
|
||||||
|
for mode <- ~w(CW SSB FM FT8 FT4) do
|
||||||
|
attrs = Map.put(@valid_attrs, :mode, mode)
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
assert changeset.valid?, "Expected mode #{mode} to be valid"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects invalid band" do
|
||||||
|
attrs = Map.put(@valid_attrs, :band, "144")
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
assert errors_on(changeset).band
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts valid microwave bands" do
|
||||||
|
for band <- ~w(1296 2304 3456 5760 10000 24000 47000 75000) do
|
||||||
|
attrs = Map.put(@valid_attrs, :band, band)
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
assert changeset.valid?, "Expected band #{band} to be valid"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects email without @" do
|
||||||
|
attrs = Map.put(@valid_attrs, :submitter_email, "notanemail")
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
assert errors_on(changeset).submitter_email
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not cast user_submitted" do
|
||||||
|
attrs = Map.put(@valid_attrs, :user_submitted, true)
|
||||||
|
changeset = Qso.submission_changeset(%Qso{}, attrs)
|
||||||
|
refute Ecto.Changeset.get_change(changeset, :user_submitted)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -345,4 +345,65 @@ defmodule Microwaveprop.RadioTest do
|
||||||
assert Radio.mark_terrain_queued!([]) == {0, nil}
|
assert Radio.mark_terrain_queued!([]) == {0, nil}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "change_qso/2" do
|
||||||
|
test "returns a submission changeset" do
|
||||||
|
changeset = Radio.change_qso(%Qso{})
|
||||||
|
assert %Ecto.Changeset{} = changeset
|
||||||
|
end
|
||||||
|
|
||||||
|
test "accepts attrs" do
|
||||||
|
changeset = Radio.change_qso(%Qso{}, %{station1: "W5XD"})
|
||||||
|
assert Ecto.Changeset.get_change(changeset, :station1) == "W5XD"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create_qso/1" do
|
||||||
|
@valid_submission %{
|
||||||
|
station1: "W5XD",
|
||||||
|
station2: "K5TR",
|
||||||
|
qso_timestamp: ~U[2026-03-28 18:00:00Z],
|
||||||
|
mode: "CW",
|
||||||
|
band: "1296",
|
||||||
|
grid1: "EM12",
|
||||||
|
grid2: "EM00",
|
||||||
|
submitter_email: "test@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
test "creates QSO with computed positions and distance" do
|
||||||
|
assert {:ok, qso} = Radio.create_qso(@valid_submission)
|
||||||
|
|
||||||
|
assert qso.station1 == "W5XD"
|
||||||
|
assert qso.user_submitted == true
|
||||||
|
assert qso.pos1["lat"]
|
||||||
|
assert qso.pos1["lon"]
|
||||||
|
assert qso.pos2["lat"]
|
||||||
|
assert qso.pos2["lon"]
|
||||||
|
assert qso.distance_km
|
||||||
|
end
|
||||||
|
|
||||||
|
test "computes correct position from grid" do
|
||||||
|
assert {:ok, qso} = Radio.create_qso(@valid_submission)
|
||||||
|
|
||||||
|
# EM12 center: lat = 4*10 - 90 + 1*1 + 0.5 = -48.5... wait
|
||||||
|
# E=4, M=12 -> lon = 4*20 - 180 + 1*2 + 1 = -97, lat = 12*10 - 90 + 2*1 + 0.5 = 32.5
|
||||||
|
assert_in_delta qso.pos1["lat"], 32.5, 0.1
|
||||||
|
assert_in_delta qso.pos1["lon"], -97.0, 0.1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "sets user_submitted to true" do
|
||||||
|
assert {:ok, qso} = Radio.create_qso(@valid_submission)
|
||||||
|
assert qso.user_submitted == true
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error changeset for invalid data" do
|
||||||
|
assert {:error, %Ecto.Changeset{}} = Radio.create_qso(%{})
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error changeset for invalid grid" do
|
||||||
|
attrs = Map.put(@valid_submission, :grid1, "ZZ99")
|
||||||
|
assert {:error, changeset} = Radio.create_qso(attrs)
|
||||||
|
assert errors_on(changeset).grid1
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
92
test/microwaveprop_web/live/submit_live_test.exs
Normal file
92
test/microwaveprop_web/live/submit_live_test.exs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
defmodule MicrowavepropWeb.SubmitLiveTest do
|
||||||
|
use MicrowavepropWeb.ConnCase, async: true
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Microwaveprop.Radio.Qso
|
||||||
|
alias Microwaveprop.Repo
|
||||||
|
alias Microwaveprop.Terrain.ElevationClient
|
||||||
|
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(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
|
||||||
|
|
||||||
|
describe "mount" do
|
||||||
|
test "renders the submission form", %{conn: conn} do
|
||||||
|
{:ok, _lv, html} = live(conn, ~p"/submit")
|
||||||
|
assert html =~ "Submit QSO"
|
||||||
|
assert html =~ "Station 1"
|
||||||
|
assert html =~ "Station 2"
|
||||||
|
assert html =~ "Grid 1"
|
||||||
|
assert html =~ "Grid 2"
|
||||||
|
assert html =~ "Band"
|
||||||
|
assert html =~ "Mode"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "validate" do
|
||||||
|
test "shows validation errors on change", %{conn: conn} do
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/submit")
|
||||||
|
|
||||||
|
html =
|
||||||
|
lv
|
||||||
|
|> form("#qso-form", qso: %{station1: ""})
|
||||||
|
|> render_change()
|
||||||
|
|
||||||
|
assert html =~ "can't be blank"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "save" do
|
||||||
|
test "creates QSO and redirects on valid submit", %{conn: conn} do
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/submit")
|
||||||
|
|
||||||
|
lv
|
||||||
|
|> form("#qso-form",
|
||||||
|
qso: %{
|
||||||
|
station1: "W5XD",
|
||||||
|
station2: "K5TR",
|
||||||
|
qso_timestamp: "2026-03-28T18:00",
|
||||||
|
mode: "CW",
|
||||||
|
band: "1296",
|
||||||
|
grid1: "EM12",
|
||||||
|
grid2: "EM00",
|
||||||
|
submitter_email: "test@example.com"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
qso = Repo.one!(Qso)
|
||||||
|
assert qso.station1 == "W5XD"
|
||||||
|
assert qso.user_submitted == true
|
||||||
|
assert qso.pos1["lat"]
|
||||||
|
|
||||||
|
assert_redirect(lv, ~p"/qsos/#{qso.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows errors on invalid submit", %{conn: conn} do
|
||||||
|
{:ok, lv, _html} = live(conn, ~p"/submit")
|
||||||
|
|
||||||
|
html =
|
||||||
|
lv
|
||||||
|
|> form("#qso-form", qso: %{station1: ""})
|
||||||
|
|> render_submit()
|
||||||
|
|
||||||
|
assert html =~ "can't be blank"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Reference in a new issue