From 7d0978f7d57b3c901814222eafc255f7cd5042bb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 29 Mar 2026 16:46:35 -0500 Subject: [PATCH] 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. --- lib/microwaveprop/radio.ex | 28 ++++ lib/microwaveprop/radio/maidenhead.ex | 41 ++++++ lib/microwaveprop/radio/qso.ex | 29 ++++ lib/microwaveprop_web/live/qso_live/index.ex | 5 + lib/microwaveprop_web/live/qso_live/show.ex | 6 + lib/microwaveprop_web/live/submit_live.ex | 125 ++++++++++++++++++ lib/microwaveprop_web/router.ex | 1 + ...29213831_add_submission_fields_to_qsos.exs | 10 ++ test/microwaveprop/radio/maidenhead_test.exs | 94 +++++++++++++ .../radio/qso_submission_test.exs | 95 +++++++++++++ test/microwaveprop/radio_test.exs | 61 +++++++++ .../live/submit_live_test.exs | 92 +++++++++++++ 12 files changed, 587 insertions(+) create mode 100644 lib/microwaveprop/radio/maidenhead.ex create mode 100644 lib/microwaveprop_web/live/submit_live.ex create mode 100644 priv/repo/migrations/20260329213831_add_submission_fields_to_qsos.exs create mode 100644 test/microwaveprop/radio/maidenhead_test.exs create mode 100644 test/microwaveprop/radio/qso_submission_test.exs create mode 100644 test/microwaveprop_web/live/submit_live_test.exs diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index d3608f52..45b6a929 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -3,6 +3,7 @@ defmodule Microwaveprop.Radio do import Ecto.Query + alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Qso alias Microwaveprop.Repo @@ -124,4 +125,31 @@ defmodule Microwaveprop.Radio do def get_qso!(id) do Repo.get!(Qso, id) 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 diff --git a/lib/microwaveprop/radio/maidenhead.ex b/lib/microwaveprop/radio/maidenhead.ex new file mode 100644 index 00000000..ad730d09 --- /dev/null +++ b/lib/microwaveprop/radio/maidenhead.ex @@ -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 + <> <> rest = grid + + lon = (f1 - ?A) * 20 + (s1 - ?0) * 2 - 180 + lat = (f2 - ?A) * 10 + (s2 - ?0) * 1 - 90 + + case rest do + <> -> + 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 diff --git a/lib/microwaveprop/radio/qso.ex b/lib/microwaveprop/radio/qso.ex index 5dc3bec4..4d772b43 100644 --- a/lib/microwaveprop/radio/qso.ex +++ b/lib/microwaveprop/radio/qso.ex @@ -4,6 +4,8 @@ defmodule Microwaveprop.Radio.Qso do import Ecto.Changeset + alias Microwaveprop.Radio.Maidenhead + @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id @@ -21,6 +23,8 @@ defmodule Microwaveprop.Radio.Qso do field :weather_queued, :boolean, default: false field :hrrr_queued, :boolean, default: false field :terrain_queued, :boolean, default: false + field :user_submitted, :boolean, default: false + field :submitter_email, :string timestamps(type: :utc_datetime) end @@ -33,4 +37,29 @@ defmodule Microwaveprop.Radio.Qso do |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) 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 diff --git a/lib/microwaveprop_web/live/qso_live/index.ex b/lib/microwaveprop_web/live/qso_live/index.ex index a298178b..7b987589 100644 --- a/lib/microwaveprop_web/live/qso_live/index.ex +++ b/lib/microwaveprop_web/live/qso_live/index.ex @@ -63,6 +63,11 @@ defmodule MicrowavepropWeb.QsoLive.Index do <.header> QSOs <:subtitle>{@total_entries} contacts + <:actions> + <.link navigate={~p"/submit"} class="btn btn-sm btn-primary"> + <.icon name="hero-plus" class="w-4 h-4" /> Submit QSO + + <.table diff --git a/lib/microwaveprop_web/live/qso_live/show.ex b/lib/microwaveprop_web/live/qso_live/show.ex index 9260dc8f..98f42fa1 100644 --- a/lib/microwaveprop_web/live/qso_live/show.ex +++ b/lib/microwaveprop_web/live/qso_live/show.ex @@ -162,6 +162,12 @@ defmodule MicrowavepropWeb.QsoLive.Show do <:item title="Grid 2">{@qso.grid2 || "—"} <:item title="Distance">{@qso.distance_km || "—"} km <:item title="Timestamp">{Calendar.strftime(@qso.qso_timestamp, "%Y-%m-%d %H:%M UTC")} + <:item :if={@qso.user_submitted} title="Source"> + User Submitted + + <:item :if={@qso.user_submitted && @qso.submitter_email} title="Submitter"> + {@qso.submitter_email} +
diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex new file mode 100644 index 00000000..dd7e35c0 --- /dev/null +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -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""" + + <.header> + Submit QSO + <:subtitle>Submit a microwave QSO for propagation analysis + + + <.form for={@form} id="qso-form" phx-change="validate" phx-submit="save" class="space-y-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" + /> +
+ +

+ Be as specific as possible with grid squares (6 characters preferred, e.g. EM12kp). +

+ +
+ <.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)" /> +
+ + <.input + field={@form[:submitter_email]} + type="email" + label="Your Email" + placeholder="you@example.com" + /> + +
+ <.button phx-disable-with="Submitting..." class="btn-primary">Submit QSO +
+ +
+ """ + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 9500ca04..5b75f1f4 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -19,6 +19,7 @@ defmodule MicrowavepropWeb.Router do get "/", PageController, :home + live "/submit", SubmitLive live "/qsos", QsoLive.Index live "/qsos/:id", QsoLive.Show end diff --git a/priv/repo/migrations/20260329213831_add_submission_fields_to_qsos.exs b/priv/repo/migrations/20260329213831_add_submission_fields_to_qsos.exs new file mode 100644 index 00000000..020a791c --- /dev/null +++ b/priv/repo/migrations/20260329213831_add_submission_fields_to_qsos.exs @@ -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 diff --git a/test/microwaveprop/radio/maidenhead_test.exs b/test/microwaveprop/radio/maidenhead_test.exs new file mode 100644 index 00000000..b60e8bf1 --- /dev/null +++ b/test/microwaveprop/radio/maidenhead_test.exs @@ -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 diff --git a/test/microwaveprop/radio/qso_submission_test.exs b/test/microwaveprop/radio/qso_submission_test.exs new file mode 100644 index 00000000..f1685036 --- /dev/null +++ b/test/microwaveprop/radio/qso_submission_test.exs @@ -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 diff --git a/test/microwaveprop/radio_test.exs b/test/microwaveprop/radio_test.exs index bbfb41d4..a93e690c 100644 --- a/test/microwaveprop/radio_test.exs +++ b/test/microwaveprop/radio_test.exs @@ -345,4 +345,65 @@ defmodule Microwaveprop.RadioTest do assert Radio.mark_terrain_queued!([]) == {0, nil} 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 diff --git a/test/microwaveprop_web/live/submit_live_test.exs b/test/microwaveprop_web/live/submit_live_test.exs new file mode 100644 index 00000000..47a8a68f --- /dev/null +++ b/test/microwaveprop_web/live/submit_live_test.exs @@ -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