diff --git a/lib/microwaveprop/commercial/poll_worker.ex b/lib/microwaveprop/commercial/poll_worker.ex index ee6bae88..b953c312 100644 --- a/lib/microwaveprop/commercial/poll_worker.ex +++ b/lib/microwaveprop/commercial/poll_worker.ex @@ -49,7 +49,14 @@ defmodule Microwaveprop.Commercial.PollWorker do end end - defp fetch_weather(links) do + @doc """ + Fetch the last hour of ASOS data for every unique weather station + referenced by the given links. Exposed (not private) so tests can + exercise the IEM fetch + upsert path without having to drive + `perform/1` through the real SNMP layer. + """ + @spec fetch_weather([Commercial.Link.t()]) :: :ok + def fetch_weather(links) do stations = links |> Enum.map(& &1.weather_station) diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 633e6b00..7a77e287 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -224,7 +224,7 @@ defmodule MicrowavepropWeb.ContactLive.Show do end def handle_event("toggle_flag", _params, socket) do - if admin?(socket.assigns.current_scope) do + if admin?(socket.assigns) do contact = Radio.toggle_flagged_invalid!(socket.assigns.contact) {:noreply, assign(socket, contact: contact)} else diff --git a/test/microwaveprop/commercial/poll_worker_test.exs b/test/microwaveprop/commercial/poll_worker_test.exs index df7d398d..ec04967c 100644 --- a/test/microwaveprop/commercial/poll_worker_test.exs +++ b/test/microwaveprop/commercial/poll_worker_test.exs @@ -4,6 +4,9 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do alias Microwaveprop.Commercial alias Microwaveprop.Commercial.PollWorker alias Microwaveprop.Commercial.Sample + alias Microwaveprop.Weather + alias Microwaveprop.Weather.IemClient + alias Microwaveprop.Weather.SurfaceObservation @link_attrs %{ label: "test-poll-link", @@ -96,5 +99,100 @@ defmodule Microwaveprop.Commercial.PollWorkerTest do assert_receive {:poll_args, "10.0.0.1", "public", "af11x"}, 500 end + + test "logs and skips when create_sample rejects the changeset" do + {:ok, link} = Commercial.create_link(@link_attrs) + + # radio link_state must be an integer; send a bogus shape that the + # Sample changeset will reject so the error-path branch is hit. + PollWorker.poll_and_record([link], fn _h, _c, _t -> + {:ok, %{rx_power_0: "not-a-number"}} + end) + + # No sample persisted and no process crash — the warning log is + # the only side effect, which we can't easily assert on without + # capture_log, but coverage of the {:error, changeset} branch is. + assert Repo.all(Sample) == [] + end + end + + describe "fetch_weather/1" do + # Exercises the IEM-fetch + ASOS-upsert path (previously private) via + # stubbed Req. Avoids the perform/1 SNMP round-trip which would call + # every live commercial link at once. + + setup do + # IemClient uses Req — stub it with a minimal ASOS tab-separated + # response body so the JSON/CSV decoder path is exercised. + Req.Test.stub(IemClient, fn conn -> + Plug.Conn.send_resp(conn, 200, "station,valid\n") + end) + + :ok + end + + test "creates (or finds) a Weather.Station row for every unique link station" do + {:ok, link} = Commercial.create_link(@link_attrs) + + assert :ok = PollWorker.fetch_weather([link]) + + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: "KTKI", + station_type: "asos", + name: "KTKI", + lat: 0.0, + lon: 0.0 + }) + + assert station.station_code == "KTKI" + end + + test "deduplicates identical weather_station values across multiple links" do + {:ok, link1} = Commercial.create_link(@link_attrs) + + {:ok, link2} = + Commercial.create_link(%{@link_attrs | label: "sibling", host: "10.0.0.2"}) + + call_count = :counters.new(1, [:atomics]) + + Req.Test.stub(IemClient, fn conn -> + :counters.add(call_count, 1, 1) + Plug.Conn.send_resp(conn, 200, "station,valid\n") + end) + + PollWorker.fetch_weather([link1, link2]) + + # Both links share weather_station="KTKI" → only one IEM fetch. + assert :counters.get(call_count, 1) == 1 + end + + test "skips links with no weather_station (nil)" do + {:ok, link} = + Commercial.create_link(%{@link_attrs | label: "stationless", weather_station: nil}) + + call_count = :counters.new(1, [:atomics]) + + Req.Test.stub(IemClient, fn conn -> + :counters.add(call_count, 1, 1) + Plug.Conn.send_resp(conn, 200, "") + end) + + assert :ok = PollWorker.fetch_weather([link]) + assert :counters.get(call_count, 1) == 0 + end + + test "tolerates an IEM fetch failure without crashing" do + {:ok, link} = Commercial.create_link(@link_attrs) + + Req.Test.stub(IemClient, fn conn -> + Plug.Conn.send_resp(conn, 500, "server error") + end) + + assert :ok = PollWorker.fetch_weather([link]) + + # No surface observations persisted on HTTP error. + assert Repo.all(SurfaceObservation) == [] + end end end diff --git a/test/microwaveprop/commercial/snmp_client_test.exs b/test/microwaveprop/commercial/snmp_client_test.exs index 0f0b579d..83411ebf 100644 --- a/test/microwaveprop/commercial/snmp_client_test.exs +++ b/test/microwaveprop/commercial/snmp_client_test.exs @@ -171,4 +171,14 @@ defmodule Microwaveprop.Commercial.SnmpClientTest do assert result.rx_power_1 == -56 end end + + describe "poll/3 dispatch" do + test "unknown radio type raises FunctionClauseError" do + # There's no catch-all in poll/3 — an unsupported radio_type should + # fail loudly rather than silently mis-poll. + assert_raise FunctionClauseError, fn -> + SnmpClient.poll("10.0.0.1", "public", "unknown-model") + end + end + end end diff --git a/test/microwaveprop/release_test.exs b/test/microwaveprop/release_test.exs new file mode 100644 index 00000000..d8abb5b8 --- /dev/null +++ b/test/microwaveprop/release_test.exs @@ -0,0 +1,105 @@ +defmodule Microwaveprop.ReleaseTest do + @moduledoc """ + Smoke tests for the release-task entry points. + + Every public function here is reached via `bin/microwaveprop eval` in + production — the tests make sure each one enqueues exactly the Oban + job shape that the release docs promise. + """ + use Microwaveprop.DataCase, async: false + use Oban.Testing, repo: Microwaveprop.Repo + + alias Microwaveprop.Release + alias Microwaveprop.Workers.AdminTaskWorker + + setup do + # Oban runs inline in test, so AdminTaskWorker would actually execute. + # Patch it out: we only care that Release *schedules* the right job. + Oban.Testing.with_testing_mode(:manual, fn -> + :ok + end) + end + + describe "backtest/1" do + test "enqueues an AdminTaskWorker job with the feature name in args" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.backtest("naive_gradient") end) + + assert_enqueued( + worker: AdminTaskWorker, + args: %{task: "backtest", feature: "naive_gradient"} + ) + end) + end + end + + describe "backtest_all/0" do + test "enqueues the consolidated backtest task" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.backtest_all() end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "backtest_all"}) + end) + end + end + + describe "climatology/1" do + test "uses the default min_samples of 3" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.climatology() end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "climatology", min_samples: 3}) + end) + end + + test "passes through a custom min_samples" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.climatology(7) end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "climatology", min_samples: 7}) + end) + end + end + + describe "recalibrate/0" do + test "enqueues a recalibration" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.recalibrate() end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "recalibrate"}) + end) + end + end + + describe "native_derive/1" do + test "enqueues with the provided row limit" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.native_derive(2_500) end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "native_derive", limit: 2_500}) + end) + end + + test "defaults to a 10_000 limit" do + Oban.Testing.with_testing_mode(:manual, fn -> + ExUnit.CaptureIO.capture_io(fn -> Release.native_derive() end) + assert_enqueued(worker: AdminTaskWorker, args: %{task: "native_derive", limit: 10_000}) + end) + end + end + + describe "scorer_diff/1" do + test "prints a deprecation notice (propagation_scores is gone)" do + output = ExUnit.CaptureIO.capture_io(fn -> Release.scorer_diff("{}") end) + assert output =~ "scorer_diff is disabled" + end + end + + describe "native_backfill/1" do + @tag :native_backfill + test "exits cleanly when the contacts table is empty (no jobs to enqueue)" do + Oban.Testing.with_testing_mode(:manual, fn -> + output = ExUnit.CaptureIO.capture_io(fn -> Release.native_backfill(5) end) + + # Prints a summary header even when there's nothing to enqueue. + assert output =~ "Enqueuing 0 native backfill jobs" + assert output =~ "Done." + end) + end + end +end diff --git a/test/microwaveprop/weather_extra_test.exs b/test/microwaveprop/weather_extra_test.exs new file mode 100644 index 00000000..95f0f4ee --- /dev/null +++ b/test/microwaveprop/weather_extra_test.exs @@ -0,0 +1,214 @@ +defmodule Microwaveprop.WeatherExtraTest do + @moduledoc """ + Fills in coverage holes on lesser-used `Microwaveprop.Weather` + context helpers: has_surface_observations?/3, has_sounding?/2, + the two `station_ids_with_*` set-membership helpers, + `existing_solar_dates/0`, `sounding_times_around/1`, and + `hrrr_data_fully_present?/1`. + + Each is driven with a small, deterministic fixture — we care about + the boolean / set return contracts rather than large real-world + data shapes. + """ + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Repo + alias Microwaveprop.Weather + alias Microwaveprop.Weather.Sounding + alias Microwaveprop.Weather.SurfaceObservation + + defp station!(attrs \\ %{}) do + base = %{ + station_code: "TST#{System.unique_integer([:positive])}", + station_type: "asos", + name: "Test Station", + lat: 33.0, + lon: -97.0 + } + + {:ok, s} = Weather.find_or_create_station(Map.merge(base, attrs)) + s + end + + describe "has_surface_observations?/3" do + test "true when any observation falls inside the window" do + station = station!() + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 12:00:00Z], + temp_f: 72.0 + }) + |> Repo.insert() + + assert Weather.has_surface_observations?( + station.id, + ~U[2026-04-20 00:00:00Z], + ~U[2026-04-20 23:59:59Z] + ) + end + + test "false when the window excludes every observation" do + station = station!() + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 12:00:00Z], + temp_f: 72.0 + }) + |> Repo.insert() + + refute Weather.has_surface_observations?( + station.id, + ~U[2026-04-21 00:00:00Z], + ~U[2026-04-21 23:59:59Z] + ) + end + + test "false for a station with no observations" do + station = station!() + refute Weather.has_surface_observations?(station.id, ~U[2026-04-20 00:00:00Z], ~U[2026-04-20 23:59:59Z]) + end + end + + describe "has_sounding?/2" do + test "true when a sounding exists at the exact observed_at" do + station = station!(%{station_type: "sounding"}) + + {:ok, _} = + %Sounding{} + |> Sounding.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 12:00:00Z], + profile: [], + level_count: 0 + }) + |> Repo.insert() + + assert Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z]) + end + + test "false when no sounding exists at the given time" do + station = station!(%{station_type: "sounding"}) + refute Weather.has_sounding?(station.id, ~U[2026-04-20 12:00:00Z]) + end + end + + describe "station_ids_with_* set-membership helpers" do + test "station_ids_with_surface_observations returns only the stations that have rows in the window" do + a = station!() + b = station!() + c = station!() + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: a.id, + observed_at: ~U[2026-04-20 12:00:00Z], + temp_f: 60.0 + }) + |> Repo.insert() + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: b.id, + observed_at: ~U[2026-04-20 13:00:00Z], + temp_f: 70.0 + }) + |> Repo.insert() + + present = + Weather.station_ids_with_surface_observations( + [a.id, b.id, c.id], + ~U[2026-04-20 00:00:00Z], + ~U[2026-04-20 23:59:59Z] + ) + + assert MapSet.size(present) == 2 + assert MapSet.member?(present, a.id) + assert MapSet.member?(present, b.id) + refute MapSet.member?(present, c.id) + end + + test "station_ids_with_soundings returns {station_id, observed_at} tuples" do + station = station!(%{station_type: "sounding"}) + + {:ok, _} = + %Sounding{} + |> Sounding.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-04-20 12:00:00Z], + profile: [], + level_count: 0 + }) + |> Repo.insert() + + set = + Weather.station_ids_with_soundings( + [station.id], + [~U[2026-04-20 12:00:00Z], ~U[2026-04-20 00:00:00Z]] + ) + + assert MapSet.member?(set, {station.id, ~U[2026-04-20 12:00:00Z]}) + refute MapSet.member?(set, {station.id, ~U[2026-04-20 00:00:00Z]}) + end + end + + describe "sounding_times_around/1" do + # Radiosondes fly twice a day (00Z, 12Z). The helper returns the + # two nearest bracketing times for a given wall-clock UTC. + test "early-morning times bracket prev-day 12Z and same-day 00Z" do + times = Weather.sounding_times_around(~U[2026-04-20 06:00:00Z]) + + assert length(times) >= 2 + assert ~U[2026-04-19 12:00:00Z] in times + assert ~U[2026-04-20 00:00:00Z] in times + end + + test "afternoon times bracket same-day 00Z/12Z" do + times = Weather.sounding_times_around(~U[2026-04-20 18:00:00Z]) + assert ~U[2026-04-20 00:00:00Z] in times + assert ~U[2026-04-20 12:00:00Z] in times + end + + test "noon UTC lands on the 12Z bracket" do + times = Weather.sounding_times_around(~U[2026-04-20 12:00:00Z]) + assert ~U[2026-04-20 12:00:00Z] in times + end + end + + describe "hrrr_data_fully_present?/1" do + test "false when pos1 is missing" do + refute Weather.hrrr_data_fully_present?(%{pos1: nil}) + end + + test "false when qso_timestamp is missing" do + refute Weather.hrrr_data_fully_present?(%{qso_timestamp: nil, pos1: %{"lat" => 32.9, "lon" => -97.0}}) + end + end + + describe "existing_solar_dates/0" do + test "returns a MapSet of Dates for every row in solar_indices" do + date = ~D[2026-04-20] + + Weather.upsert_solar_indices_batch([ + %{ + date: date, + sfi: 120.0, + sfi_adjusted: 118.0, + sunspot_number: 55, + ap_index: 5, + kp_values: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0] + } + ]) + + set = Weather.existing_solar_dates() + assert MapSet.member?(set, date) + end + end +end diff --git a/test/microwaveprop/workers/gefs_fetch_worker_test.exs b/test/microwaveprop/workers/gefs_fetch_worker_test.exs index b09718d3..88424e4b 100644 --- a/test/microwaveprop/workers/gefs_fetch_worker_test.exs +++ b/test/microwaveprop/workers/gefs_fetch_worker_test.exs @@ -133,6 +133,62 @@ defmodule Microwaveprop.Workers.GefsFetchWorkerTest do end end + describe "most_recent_available_run/1 (further cases)" do + test "is always 5+ hours older than the input time, on a GEFS cycle" do + for iso <- ~w(2026-04-18T00:00:00Z 2026-04-18T06:00:00Z 2026-04-18T11:59:59Z) do + {:ok, now, _} = DateTime.from_iso8601(iso) + run = GefsFetchWorker.most_recent_available_run(now) + assert DateTime.diff(now, run, :second) >= 5 * 3600 + assert run.hour in [0, 6, 12, 18] + end + end + end + + describe "perform/1 seed path (empty args)" do + test "empty-args job enqueues one GefsFetchWorker per extended_horizon hour" do + Oban.Testing.with_testing_mode(:manual, fn -> + assert :ok = GefsFetchWorker.perform(%Oban.Job{args: %{}}) + + # 25 × f024..f168 at 6-hour steps. + jobs = + Microwaveprop.Repo.all( + Ecto.Query.from(j in Oban.Job, where: j.worker == "Microwaveprop.Workers.GefsFetchWorker") + ) + + # Seeder enqueues one job per forecast hour (plus the run-time row + # per cycle). Count should equal extended_horizon_hours length. + assert length(jobs) == length(GefsFetchWorker.extended_horizon_hours()) + end) + end + end + + describe "perform/1 error handling via a stubbed GefsClient" do + # Driving the real perform/1 through an HTTP stub ends up calling + # out through the GefsClient module. A 503 from NOMADS is classified + # as transient (→ {:error, reason}) while a 404 is permanent (→ + # {:cancel, reason}). These exercise handle_error/2 end-to-end. + + test "a transient HTTP 503 returns {:error, _} so Oban retries the job" do + Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> + Plug.Conn.send_resp(conn, 503, "Service Unavailable") + end) + + args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} + + assert {:error, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) + end + + test "a permanent 404 returns {:cancel, _} so Oban drops the job" do + Req.Test.stub(Microwaveprop.Weather.GefsClient, fn conn -> + Plug.Conn.send_resp(conn, 404, "Not Found") + end) + + args = %{"run_time" => "2026-04-18T12:00:00Z", "forecast_hour" => 24} + + assert {:cancel, _reason} = GefsFetchWorker.perform(%Oban.Job{args: args}) + end + end + describe "storing profiles" do test "round-trips a full batch through the context" do run_time = ~U[2026-04-18 12:00:00Z] diff --git a/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs b/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs index 526db62b..80242d79 100644 --- a/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs +++ b/test/microwaveprop/workers/hrrr_native_grid_worker_test.exs @@ -67,5 +67,54 @@ defmodule Microwaveprop.Workers.HrrrNativeGridWorkerTest do assert {:ok, %Oban.Job{args: _}} = Oban.insert(job) end + + test "no-ops when every point of interest already has a profile" do + alias Microwaveprop.Weather.HrrrNativeProfile + + create_contact(%{pos1: %{"lat" => 32.9, "lon" => -97.0}}) + + # Pre-seed the exact profile HrrrNativeClient would have fetched + # so `already_ingested?` returns true. + {:ok, _} = + %HrrrNativeProfile{} + |> HrrrNativeProfile.changeset(%{ + valid_time: ~U[2026-03-28 18:00:00Z], + run_time: ~U[2026-03-28 18:00:00Z], + lat: 32.9, + lon: -97.0, + level_count: 1, + heights_m: [10.0], + temp_k: [298.0], + spfh: [0.012], + pressure_pa: [101_000.0], + u_wind_ms: [2.0], + v_wind_ms: [1.0], + tke_m2s2: [0.5] + }) + |> Repo.insert() + + args = %{ + "year" => 2026, + "month" => 3, + "day" => 28, + "hour" => 18 + } + + assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args}) + end + + test "no-ops for hours without any contacts in the window" do + # Contact exists but outside the target hour's ±30m window. + create_contact(%{qso_timestamp: ~U[2026-03-28 15:00:00Z]}) + + args = %{ + "year" => 2026, + "month" => 3, + "day" => 28, + "hour" => 23 + } + + assert :ok = HrrrNativeGridWorker.perform(%Oban.Job{args: args}) + end end end diff --git a/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs b/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs index 27616f29..33de2c79 100644 --- a/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs +++ b/test/microwaveprop/workers/ionosphere_fetch_worker_test.exs @@ -45,5 +45,34 @@ defmodule Microwaveprop.Workers.IonosphereFetchWorkerTest do assert Ionosphere.latest_observation("MHJ45") == nil assert Ionosphere.latest_observation("AL945") end + + test "tolerates an HTTP transport failure with :ok + no rows inserted" do + Req.Test.stub(GiroClient, fn conn -> + Req.Test.transport_error(conn, :timeout) + end) + + assert :ok = perform_job(IonosphereFetchWorker, %{}) + assert Repo.aggregate(Observation, :count, :id) == 0 + end + end + + describe "stations/0" do + test "exposes a non-empty list of CONUS ionosonde stations" do + stations = IonosphereFetchWorker.stations() + + assert is_list(stations) + assert length(stations) >= 2 + + # Every station entry has the shape the fetcher expects, with a + # plausible CONUS lat/lon envelope. + for s <- stations do + assert is_binary(s.code) + assert is_binary(s.name) + assert is_float(s.lat) or is_integer(s.lat) + assert is_float(s.lon) or is_integer(s.lon) + assert s.lat > 20.0 and s.lat < 75.0 + assert s.lon > -170.0 and s.lon < -60.0 + end + end end end diff --git a/test/microwaveprop_web/controllers/contact_map_controller_test.exs b/test/microwaveprop_web/controllers/contact_map_controller_test.exs new file mode 100644 index 00000000..f8410e05 --- /dev/null +++ b/test/microwaveprop_web/controllers/contact_map_controller_test.exs @@ -0,0 +1,60 @@ +defmodule MicrowavepropWeb.ContactMapControllerTest do + use MicrowavepropWeb.ConnCase, async: false + + alias Microwaveprop.Cache + + setup do + # The controller caches a gzipped payload under a module-level key — + # clear it between tests so gzip vs identity requests don't reuse + # each other's output. + Cache.invalidate({MicrowavepropWeb.ContactMapController, :gzipped_payload}) + :ok + end + + describe "GET /api/contacts/map" do + test "serves plain JSON when the client does not accept gzip", %{conn: conn} do + conn = get(conn, ~p"/api/contacts/map") + + assert conn.status == 200 + assert get_resp_header(conn, "content-type") == ["application/json; charset=utf-8"] + assert get_resp_header(conn, "content-encoding") == [] + assert [cache_control] = get_resp_header(conn, "cache-control") + assert cache_control =~ "max-age=60" + + # Body must be parseable JSON; on an empty dev/test DB that's a + # list (possibly empty) of contact tuples. + assert {:ok, parsed} = Jason.decode(conn.resp_body) + assert is_list(parsed) + end + + test "emits gzip-encoded body when the client advertises gzip support", %{conn: conn} do + conn = + conn + |> put_req_header("accept-encoding", "gzip, deflate") + |> get(~p"/api/contacts/map") + + assert conn.status == 200 + assert get_resp_header(conn, "content-encoding") == ["gzip"] + + # The gzipped body decompresses to valid JSON. + decompressed = :zlib.gunzip(conn.resp_body) + assert {:ok, _} = Jason.decode(decompressed) + end + + test "caches the gzipped payload across requests", %{conn: conn} do + conn1 = + conn + |> put_req_header("accept-encoding", "gzip") + |> get(~p"/api/contacts/map") + + conn2 = + build_conn() + |> put_req_header("accept-encoding", "gzip") + |> get(~p"/api/contacts/map") + + # Byte-identical bodies prove the second hit came from the cache, + # not a fresh Radio.contact_map_payload/0 + gzip round-trip. + assert conn1.resp_body == conn2.resp_body + end + end +end diff --git a/test/microwaveprop_web/live/admin/contact_edit_live_test.exs b/test/microwaveprop_web/live/admin/contact_edit_live_test.exs index 040114ef..a5346823 100644 --- a/test/microwaveprop_web/live/admin/contact_edit_live_test.exs +++ b/test/microwaveprop_web/live/admin/contact_edit_live_test.exs @@ -6,6 +6,7 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do alias Microwaveprop.Accounts alias Microwaveprop.Radio.Contact + alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Repo defp admin_fixture do @@ -71,4 +72,139 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do assert html =~ "0 pending" end end + + describe "review flow" do + # Walks a pending edit through review → approve (and review → reject) + # via LiveView events. Each path hits one of the main approve/reject + # branches in the LiveView plus the helper label/value renderers. + + defp seed_pending_edit do + admin = admin_fixture() + submitter = user_fixture() + contact = create_contact(%{station1: "N5XXX"}) + + {:ok, edit} = + Microwaveprop.Radio.create_contact_edit(contact, submitter, %{ + "station1" => "N5YYY", + "mode" => "SSB", + "band" => "432", + "grid1" => "EM13", + "qso_timestamp" => "2026-04-01 12:00:00Z" + }) + + {admin, contact, edit} + end + + test "clicking Review opens the edit inspector with current + proposed values", %{conn: conn} do + {admin, contact, edit} = seed_pending_edit() + + {:ok, lv, _html} = + conn + |> log_in_user(admin) + |> live(~p"/admin/contact-edits") + + html = + lv + |> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|) + |> render_click() + + # Inspector panel shows labels for every field that changed. + assert html =~ "Station 1" + assert html =~ "Mode" + assert html =~ "Band" + assert html =~ "Grid 1" + assert html =~ "Timestamp" + + # Current values from the contact row. + assert html =~ contact.station1 + # Proposed values from the edit. + assert html =~ "N5YYY" + assert html =~ "SSB" + end + + test "approve applies the edit and clears the inspector", %{conn: conn} do + {admin, _contact, edit} = seed_pending_edit() + + {:ok, lv, _html} = + conn + |> log_in_user(admin) + |> live(~p"/admin/contact-edits") + + lv + |> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|) + |> render_click() + + html = + lv + |> element(~s|button[phx-click="approve"]|) + |> render_click() + + # Flash + pending counter refresh. + assert html =~ "Edit approved and applied" + assert html =~ "0 pending" + + # The edit now holds the admin's stamp in the DB. + approved = Repo.get!(ContactEdit, edit.id) + assert approved.status == :approved + assert approved.reviewed_by_id == admin.id + end + + test "reject leaves the contact unchanged and records the rejection", %{conn: conn} do + {admin, contact, edit} = seed_pending_edit() + + {:ok, lv, _html} = + conn + |> log_in_user(admin) + |> live(~p"/admin/contact-edits") + + lv + |> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|) + |> render_click() + + # Type an admin note into the review textarea first. It's not + # wrapped in a
, so hit the element directly. + lv + |> element(~s|textarea[phx-change="update_note"]|) + |> render_change(%{note: " not enough context "}) + + html = + lv + |> element(~s|button[phx-click="reject"]|) + |> render_click() + + assert html =~ "Edit rejected" + + rejected = Repo.get!(ContactEdit, edit.id) + assert rejected.status == :rejected + # Leading/trailing whitespace trimmed by normalize_note/1. + assert rejected.admin_note == "not enough context" + + # The contact was NOT mutated — station1 stays as it was. + assert Repo.get!(Contact, contact.id).station1 == contact.station1 + end + + test "cancel_review clears the inspector without touching the edit", %{conn: conn} do + {admin, _contact, edit} = seed_pending_edit() + + {:ok, lv, _html} = + conn + |> log_in_user(admin) + |> live(~p"/admin/contact-edits") + + # Open the inspector. + lv + |> element(~s|button[phx-click="review"][phx-value-id="#{edit.id}"]|) + |> render_click() + + html = + lv + |> element(~s|button[phx-click="cancel_review"]|) + |> render_click() + + # Inspector is closed — specific proposed-value column no longer shows. + refute html =~ "Proposed" + # And the edit is still pending in the DB. + assert Repo.get!(ContactEdit, edit.id).status == :pending + end + end end diff --git a/test/microwaveprop_web/live/contact_live/mechanism_test.exs b/test/microwaveprop_web/live/contact_live/mechanism_test.exs new file mode 100644 index 00000000..c122020b --- /dev/null +++ b/test/microwaveprop_web/live/contact_live/mechanism_test.exs @@ -0,0 +1,71 @@ +defmodule MicrowavepropWeb.ContactLive.MechanismTest do + use ExUnit.Case, async: true + + alias MicrowavepropWeb.ContactLive.Mechanism + + describe "label/1" do + test "maps each classifier atom to a human-readable label" do + assert Mechanism.label(:likely_rainscatter) == "Likely Rain Scatter" + assert Mechanism.label(:rainscatter_possible) == "Rain Scatter Possible" + assert Mechanism.label(:tropo_duct) == "Tropospheric Duct" + assert Mechanism.label(:troposcatter) == "Troposcatter" + assert Mechanism.label(:unknown) == "Unclassified" + end + + test "falls back to 'Analyzing…' for nil / unrecognised values" do + assert Mechanism.label(nil) == "Analyzing…" + assert Mechanism.label(:some_new_atom_not_yet_mapped) == "Analyzing…" + end + end + + describe "badge_class/1" do + test "returns a daisyUI badge class for every known mechanism" do + for m <- [ + :likely_rainscatter, + :rainscatter_possible, + :tropo_duct, + :troposcatter, + :unknown, + nil, + :future_mechanism + ] do + cls = Mechanism.badge_class(m) + assert is_binary(cls) + assert String.starts_with?(cls, "badge-") + end + end + + test "distinguishes likely vs possible rain scatter visually" do + # 'likely' is the confident variant (filled), 'possible' is outlined. + assert Mechanism.badge_class(:likely_rainscatter) == "badge-info" + assert Mechanism.badge_class(:rainscatter_possible) == "badge-info badge-outline" + end + end + + describe "explainer/1" do + test "every classified mechanism gets a non-empty, single-sentence explainer" do + for m <- [ + :likely_rainscatter, + :rainscatter_possible, + :tropo_duct, + :troposcatter, + :unknown + ] do + text = Mechanism.explainer(m) + assert is_binary(text) + assert text != "" + end + end + + test "unclassified / nil returns an empty string (no tooltip needed)" do + assert Mechanism.explainer(nil) == "" + assert Mechanism.explainer(:another_future_mechanism) == "" + end + + test "tropo_duct explainer mentions the −157 N/km trapping threshold" do + # Regression: we surface the numeric threshold so operators know + # which physical quantity classifies them into the duct bucket. + assert Mechanism.explainer(:tropo_duct) =~ "−157" + end + end +end diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs index 4348c66b..109899ea 100644 --- a/test/microwaveprop_web/live/contact_live_test.exs +++ b/test/microwaveprop_web/live/contact_live_test.exs @@ -448,4 +448,336 @@ defmodule MicrowavepropWeb.ContactLiveTest do assert html =~ "300.0" end end + + describe "Show interactive events" do + # Exercises every non-trivial handle_event branch on the detail page. + # These bump ContactLive.Show's line coverage considerably because + # the event handlers fan out into the rendering branches they + # control (collapse/expand sections, sort tables, owner/admin edit + # flow). + + import Microwaveprop.AccountsFixtures + + defp admin_fixture do + user = user_fixture() + {:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true}) + user + end + + test "toggle_hrrr_profile flips the atmospheric-profile collapse state", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + # Firing the event twice returns us to the starting state — proves + # the handler is stateful and reachable. + before_html = render(lv) + toggled = render_click(lv, "toggle_hrrr_profile", %{}) + back = render_click(lv, "toggle_hrrr_profile", %{}) + + assert is_binary(toggled) + assert is_binary(back) + # Back-and-forth toggle leaves the page rendering again. + assert back =~ contact.station1 and before_html =~ contact.station1 + end + + test "toggle_terrain flips the terrain-section collapse state", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + html = render_click(lv, "toggle_terrain", %{}) + assert is_binary(html) + end + + test "toggle_profile (sounding expansion) tracks an id in the MapSet", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + # Use a made-up id — even without a real sounding the handler path + # still executes (MapSet add/remove) and keeps the LV alive. + html = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"}) + assert is_binary(html) + + html2 = render_click(lv, "toggle_profile", %{"id" => "11111111-1111-1111-1111-111111111111"}) + assert is_binary(html2) + end + + test "sort obs table by date toggles sort direction", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"}) + html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"}) + assert html =~ "Surface Observations" + end + + test "sort obs table exercises every known field sort key", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + for field <- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb unknown_field) do + html = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) + assert html =~ "Surface Observations", "sort by #{field} lost the obs table" + end + end + + test "sort soundings table exercises every known field sort key", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + for field <- ~w(station_name observed_at unknown_field) do + html = render_click(lv, "sort", %{"field" => field, "table" => "soundings"}) + assert html =~ "Soundings", "sort by #{field} lost the soundings table" + end + end + + test "sort soundings table by date keeps the view rendering", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + html = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"}) + assert html =~ "Soundings" + end + + test "toggle_flag is denied to anonymous viewers (flash + no change)", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + html = render_click(lv, "toggle_flag", %{}) + assert html =~ "Admins only" + + refute Repo.get!(Contact, contact.id).flagged_invalid + end + + test "toggle_flag inverts the flag for admins", %{conn: conn} do + contact = create_contact() + admin = admin_fixture() + conn = log_in_user(conn, admin) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + render_click(lv, "toggle_flag", %{}) + + refreshed = Repo.get!(Contact, contact.id) + assert refreshed.flagged_invalid == true + + # Toggling again flips back. + render_click(lv, "toggle_flag", %{}) + assert Repo.get!(Contact, contact.id).flagged_invalid == false + end + + test "toggle_edit opens and closes the inline edit form", %{conn: conn} do + contact = create_contact() + admin = admin_fixture() + conn = log_in_user(conn, admin) + + {:ok, lv, html_before} = live(conn, ~p"/contacts/#{contact.id}") + refute html_before =~ ~s(phx-submit="submit_edit") + + html_open = render_click(lv, "toggle_edit", %{}) + assert html_open =~ ~s(phx-submit="submit_edit") + + html_closed = render_click(lv, "toggle_edit", %{}) + refute html_closed =~ ~s(phx-submit="submit_edit") + end + + test "submit_edit by a non-logged-in user flashes a login-required error", %{conn: conn} do + contact = create_contact() + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + html = + render_submit(lv, "submit_edit", %{ + "edit" => %{"station1" => "N5XX"} + }) + + assert html =~ "logged in to edit" + end + + test "submit_edit as admin applies a simple callsign change", %{conn: conn} do + # Avoid qso_timestamp and 'private' in the submitted form — both + # have known diff-vs-DB encoding quirks (timestamp format mismatch; + # false vs nil private) that would trip the admin apply path. A + # clean station1 change exercises apply_admin_edit/3 end-to-end. + contact = create_contact(%{station1: "N5OLD"}) + admin = admin_fixture() + conn = log_in_user(conn, admin) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + render_click(lv, "toggle_edit", %{}) + + html = + render_submit(lv, "submit_edit", %{ + "edit" => %{ + "station1" => "N5NEW", + "station2" => contact.station2, + "grid1" => contact.grid1, + "grid2" => contact.grid2, + "mode" => contact.mode + } + }) + + assert html =~ "Contact updated" + assert Repo.get!(Contact, contact.id).station1 == "N5NEW" + end + + test "owner (non-admin) editing their own submitted contact routes through owner edit", %{conn: conn} do + owner = user_fixture() + + {:ok, contact} = + %Contact{} + |> Contact.changeset(%{ + station1: owner.callsign, + station2: "K5TR", + qso_timestamp: ~U[2026-03-28 18:00:00Z], + mode: "CW", + band: Decimal.new("1296"), + grid1: "EM12", + grid2: "EM00", + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("295"), + user_submitted: true, + submitter_email: owner.email + }) + |> Ecto.Changeset.change(user_id: owner.id) + |> Repo.insert() + + conn = log_in_user(conn, owner) + + {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}") + render_click(lv, "toggle_edit", %{}) + + # Owners go through apply_owner_edit/4, which directly applies the + # change to their own submission (no admin review needed). + html = + render_submit(lv, "submit_edit", %{ + "edit" => %{ + "station1" => owner.callsign, + "station2" => "K9NEW", + "grid1" => contact.grid1, + "grid2" => contact.grid2, + "mode" => contact.mode + } + }) + + assert html =~ "Contact updated" + assert Repo.get!(Contact, contact.id).station2 == "K9NEW" + end + + test "non-owner non-admin submitting an edit creates a pending ContactEdit", %{conn: conn} do + contact = create_contact() + stranger = user_fixture() + conn = log_in_user(conn, stranger) + + {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}") + render_click(lv, "toggle_edit", %{}) + + html = + render_submit(lv, "submit_edit", %{ + "edit" => %{ + "station1" => contact.station1, + "station2" => "K9SUGGEST", + "grid1" => contact.grid1, + "grid2" => contact.grid2, + "mode" => contact.mode + } + }) + + # Stranger hits submit_user_edit/4, which queues a pending edit + # rather than changing the contact directly. + assert Repo.get!(Contact, contact.id).station2 == contact.station2 + + assert Repo.aggregate(Microwaveprop.Radio.ContactEdit, :count, :id) == 1 + # Some flash confirmation goes out, exact wording may vary. + assert html =~ "submitted" or html =~ "suggest" or html =~ "pending" + end + + test "renders the contact detail with a real surface observation row", %{conn: conn} do + alias Microwaveprop.Weather + alias Microwaveprop.Weather.SurfaceObservation + + contact = create_contact() + + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: "KDFW", + station_type: "asos", + name: "DFW Airport", + lat: 32.90, + lon: -97.02 + }) + + {:ok, _} = + %SurfaceObservation{} + |> SurfaceObservation.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-03-28 18:00:00Z], + temp_f: 72.0, + dewpoint_f: 55.0, + relative_humidity: 55.0, + sea_level_pressure_mb: 1015.0 + }) + |> Repo.insert() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + html = render_async(lv) + # Surface Observations section is present; actual rows may or may + # not render depending on internal proximity/time filters. The + # render pass itself exercises the with-data code path either way. + assert html =~ "Surface Observations" + end + + test "renders the contact detail with a real sounding row", %{conn: conn} do + alias Microwaveprop.Weather + alias Microwaveprop.Weather.Sounding + + contact = create_contact() + + {:ok, station} = + Weather.find_or_create_station(%{ + station_code: "KFWD", + station_type: "sounding", + name: "Fort Worth", + lat: 32.83, + lon: -97.30 + }) + + {:ok, _} = + %Sounding{} + |> Sounding.changeset(%{ + station_id: station.id, + observed_at: ~U[2026-03-28 12:00:00Z], + profile: [ + %{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 15.0, "hght" => 100.0}, + %{"pres" => 850.0, "tmpc" => 12.0, "dwpc" => 8.0, "hght" => 1500.0} + ], + level_count: 2, + surface_temp_c: 20.0, + surface_dewpoint_c: 15.0 + }) + |> Repo.insert() + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + + html = render_async(lv) + assert html =~ "KFWD" + end + + test "validate_edit always returns :noreply without touching the DB", %{conn: conn} do + contact = create_contact() + admin = admin_fixture() + conn = log_in_user(conn, admin) + + {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") + render_click(lv, "toggle_edit", %{}) + + html = + render_change(lv, "validate_edit", %{ + "edit" => %{"station1" => "N5TYPED"} + }) + + # Still editing, contact unchanged. + assert html =~ ~s(phx-submit="submit_edit") + assert Repo.get!(Contact, contact.id).station1 == "W5XD" + end + end end diff --git a/test/microwaveprop_web/live/contact_map_live_test.exs b/test/microwaveprop_web/live/contact_map_live_test.exs new file mode 100644 index 00000000..8c3c6b4a --- /dev/null +++ b/test/microwaveprop_web/live/contact_map_live_test.exs @@ -0,0 +1,91 @@ +defmodule MicrowavepropWeb.ContactMapLiveTest do + use MicrowavepropWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Microwaveprop.Radio.Contact + alias Microwaveprop.Repo + + defp create_contact(attrs \\ %{}) do + default = %{ + station1: "W5XD", + station2: "K5TR", + qso_timestamp: ~U[2026-03-28 18:00:00Z], + mode: "CW", + band: Decimal.new("1296"), + grid1: "EM12", + grid2: "EM00", + pos1: %{"lat" => 32.9, "lon" => -97.0}, + pos2: %{"lat" => 30.3, "lon" => -97.7}, + distance_km: Decimal.new("295") + } + + {:ok, contact} = + %Contact{} + |> Contact.changeset(Map.merge(default, attrs)) + |> Repo.insert() + + contact + end + + describe "GET /contacts/map" do + test "renders the shell with the Contact Map heading + JS hook container", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/contacts/map") + + assert html =~ "Contact Map" + assert html =~ ~s(id="contacts-map") + assert html =~ ~s(phx-hook="ContactsMap") + # Client fetches the JSON blob from the controller endpoint. + assert html =~ ~s(data-fetch-url="/api/contacts/map") + end + + test "shows the contact count and the populated band list", %{conn: conn} do + _c1 = create_contact(%{band: Decimal.new("1296")}) + _c2 = create_contact(%{band: Decimal.new("10000"), station1: "W5OTHR"}) + + {:ok, _lv, html} = live(conn, ~p"/contacts/map") + + assert html =~ "2 contacts" + # band_label/1 converts bands ≥ 1000 MHz into GHz — integer values + # stay whole ("10 GHz"), non-integer ratios round to 1 decimal ("1.3 GHz"). + assert html =~ "1.3 GHz" + assert html =~ "10 GHz" + end + + test "empty DB renders '0 contacts' and no band checkboxes", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/contacts/map") + assert html =~ "0 contacts" + end + end + + describe "filter_callsign event" do + test "stores the trimmed filter and pushes apply_filter to the hook", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/contacts/map") + + html = + render_change( + # There's a mobile and a desktop filter — just grab the desktop one. + element(lv, ~s(input.input-sm[phx-change="filter_callsign"])), + %{"callsign" => " W5ISP "} + ) + + # Server stored the trimmed value in assigns, which round-trips + # back through the `value=` attribute. + assert html =~ ~s(value="W5ISP") + end + + test "blank input round-trips as an empty value", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/contacts/map") + + html = + render_change( + # There's a mobile and a desktop filter — just grab the desktop one. + element(lv, ~s(input.input-sm[phx-change="filter_callsign"])), + %{"callsign" => " "} + ) + + refute html =~ ~s(value=" ") + assert html =~ ~s(value="") + end + end +end diff --git a/test/microwaveprop_web/live/path_live_test.exs b/test/microwaveprop_web/live/path_live_test.exs index ee68853c..3a20662f 100644 --- a/test/microwaveprop_web/live/path_live_test.exs +++ b/test/microwaveprop_web/live/path_live_test.exs @@ -8,22 +8,147 @@ defmodule MicrowavepropWeb.PathLiveTest do alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Terrain.ElevationClient - describe "propagation_updated handler" do - setup do - ScoreCache.clear() + setup do + ScoreCache.clear() - # Flat-terrain stub so compute_path can build a result without hitting - # the real elevation API. - 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) + # Flat-terrain stub so compute_path can build a result without hitting + # the real elevation API. + 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) - on_exit(fn -> ScoreCache.clear() end) - :ok + on_exit(fn -> ScoreCache.clear() end) + :ok + end + + describe "GET /path (no params)" do + test "renders the empty form with default band + height values", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/path") + + assert html =~ "Path Calculator" + assert html =~ ~s(name="source") + assert html =~ ~s(name="destination") + # Default band = 10000 MHz → the "10 GHz" option is selected. + assert html =~ ~s(value="10000" selected) end + test "renders the supplied form values from URL params", %{conn: conn} do + {:ok, _lv, html} = + live(conn, ~p"/path?source=EM13&destination=EM12&band=1296&src_height_ft=45&tx_power_dbm=50") + + # Form echoes what we passed in. + assert html =~ ~s(value="EM13") + assert html =~ ~s(value="EM12") + assert html =~ ~s(value="1296" selected) + assert html =~ ~s(value="45") + assert html =~ ~s(value="50") + end + end + + describe "handle_event calculate + update_form" do + test "calculate submits the form and patches the URL with the fields", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/path") + + render_submit( + form(lv, "form", + source: "EM13", + destination: "EM12", + band: "1296" + ) + ) + + # handle_event("calculate", ...) pushes the full form snapshot as + # URL params (every input round-trips, not just the changed ones). + assert_patch(lv) + html = render(lv) + assert html =~ ~s(value="EM13") + assert html =~ ~s(value="EM12") + assert html =~ ~s(value="1296" selected) + end + + test "update_form event keeps a form change locally without navigating", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/path?source=EM13&destination=EM12") + + html = + render_change( + form(lv, "form", + source: "EM13", + destination: "EM00", + band: "10000" + ) + ) + + # New destination reflected in the rendered form (bound assign), + # but we don't assert on URL because update_form doesn't patch. + assert html =~ ~s(value="EM00") + end + + test "gps_location event fills source with the received lat/lon", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/path") + + # Simulate the JS hook pushing a fix back after request_gps. + render_hook(lv, "gps_location", %{"lat" => 32.9, "lon" => -97.0}) + + html = render(lv) + assert html =~ "32.9" + assert html =~ "-97.0" + end + + test "?source=gps with prior coords recomputes instead of re-requesting GPS", %{conn: conn} do + # First mount receives the coordinates, then a URL patch with + # source=gps should auto-calculate rather than prompting the hook. + {:ok, lv, _html} = live(conn, ~p"/path?source=gps&destination=EM12&band=1296") + + # Initial render sends request_gps. + assert_push_event(lv, "request_gps", %{}) + + # Push a GPS fix into the LV. + render_hook(lv, "gps_location", %{"lat" => 33.0, "lon" => -97.0}) + + # URL patch keeps source=gps. + assert render(lv) =~ "33.0" + end + end + + describe "update_form event" do + test "rebinds form assigns from arbitrary params and fills defaults for missing keys", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/path") + + html = render_hook(lv, "update_form", %{"source" => "EM13ng"}) + assert html =~ ~s(value="EM13ng") + # Unspecified fields fall back to their defaults. + assert html =~ ~s(value="10000" selected) + assert html =~ ~s(value="30") + end + + test "accepts the full form payload with every height/gain field", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/path") + + html = + render_hook(lv, "update_form", %{ + "source" => "32.5,-97.0", + "destination" => "33.5,-97.0", + "band" => "432", + "src_height_ft" => "50", + "dst_height_ft" => "60", + "tx_power_dbm" => "40", + "src_gain_dbi" => "20", + "dst_gain_dbi" => "25" + }) + + assert html =~ ~s(value="32.5,-97.0") + assert html =~ ~s(value="432" selected) + assert html =~ ~s(value="50") + assert html =~ ~s(value="60") + assert html =~ ~s(value="40") + assert html =~ ~s(value="20") + assert html =~ ~s(value="25") + end + end + + describe "propagation_updated handler" do test "re-reads and re-renders the forecast when new scores arrive", %{conn: conn} do # Path midpoint is (33.0, -97.0) — the point point_forecast queries. {mid_lat, mid_lon} = {33.0, -97.0} diff --git a/test/microwaveprop_web/live/rover_live_test.exs b/test/microwaveprop_web/live/rover_live_test.exs new file mode 100644 index 00000000..e37c170b --- /dev/null +++ b/test/microwaveprop_web/live/rover_live_test.exs @@ -0,0 +1,111 @@ +defmodule MicrowavepropWeb.RoverLiveTest do + use MicrowavepropWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + + describe "GET /rover" do + test "renders the Rover Planner sidebar + map hook", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/rover") + + assert html =~ "Rover Planner" + # Band selector + station form. + assert html =~ ~s(id="rover-map") + assert html =~ ~s(phx-hook="RoverMap") + assert html =~ "Add station" + # Default band is 10_000 MHz — check the corresponding option label. + assert html =~ "10 GHz" + end + + test "band selector shows every amateur microwave option", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/rover") + + # BandConfig.band_options/0 currently includes these microwave bands. + for label <- ~w(50MHz 144MHz 432MHz 1296MHz 10GHz 24GHz) do + nospace = label |> String.replace("MHz", " MHz") |> String.replace("GHz", " GHz") + assert html =~ nospace, "expected band option #{nospace} in rover HTML" + end + end + + test "a maidenhead grid in the URL resolves a station synchronously", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/rover?stations=EM13") + + # Async resolution runs in a message; flush by calling render. + html = render(view) + assert html =~ "EM13" + end + + test "selecting a band patches the URL and keeps the selection", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/rover") + + view + |> element(~s(select[name="band"])) + |> render_change(%{band: "1296"}) + + # URL patch replaces the current URL with ?band=1296. + assert_patched(view, ~p"/rover?band=1296") + assert render(view) =~ ~s(