defmodule MicrowavepropWeb.PskrSpotsLiveTest do use MicrowavepropWeb.ConnCase, async: true import Phoenix.LiveViewTest alias Microwaveprop.Repo defp now, do: DateTime.utc_now() defp dump_uuid, do: Ecto.UUID.dump!(Ecto.UUID.generate()) defp insert_spot(attrs) do defaults = %{ hour_utc: now(), band: "2m", sender_grid: "EM12KL", receiver_grid: "DM43ST", spot_count: 1 } fields = Map.merge(defaults, attrs) Repo.query!( """ INSERT INTO pskr_spots_hourly (id, hour_utc, band, sender_grid, receiver_grid, sender_lat, sender_lon, receiver_lat, receiver_lon, distance_km, spot_count, max_snr_db, min_snr_db, modes, sender_callsigns, receiver_callsigns, first_spot_at, last_spot_at, inserted_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $19) """, [ dump_uuid(), fields.hour_utc, fields.band, fields.sender_grid, fields.receiver_grid, Map.get(fields, :sender_lat, 32.5), Map.get(fields, :sender_lon, -96.5), Map.get(fields, :receiver_lat, 33.5), Map.get(fields, :receiver_lon, -110.5), Map.get(fields, :distance_km, 1_200.0), fields.spot_count, Map.get(fields, :max_snr_db, 5), Map.get(fields, :min_snr_db, 0), Map.get(fields, :modes, ["FT8"]), Map.get(fields, :sender_callsigns, []), Map.get(fields, :receiver_callsigns, []), Map.get(fields, :first_spot_at, fields.hour_utc), Map.get(fields, :last_spot_at, fields.hour_utc) ] ) end describe "page rendering" do test "renders the pskr spots page", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "PSK Reporter Spots" end test "sets page title", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "PSK Reporter Spots" end test "shows empty state when no spots exist", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "No PSK Reporter spots received yet" end test "shows total spots count of zero when empty", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "Total spots stored:" assert html =~ "0" end end describe "with spots in the database" do test "displays total spots count and per-band counts", %{conn: conn} do insert_spot(%{band: "2m", spot_count: 42}) insert_spot(%{band: "70cm", spot_count: 7}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "Total spots stored:" # 42 + 7 = 49 total assert html =~ "49" # Band badges show per-band counts assert html =~ "2m" assert html =~ "70cm" end test "displays band as badge", %{conn: conn} do insert_spot(%{band: "70cm"}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "70cm" end test "displays SNR range", %{conn: conn} do insert_spot(%{min_snr_db: -5, max_snr_db: 12}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "-5–12 dB" end test "displays single SNR when min equals max", %{conn: conn} do insert_spot(%{min_snr_db: 3, max_snr_db: 3}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "3 dB" refute html =~ "3–3 dB" end test "displays sender and receiver callsigns", %{conn: conn} do insert_spot(%{sender_callsigns: ["K5ABC"], receiver_callsigns: ["K7XYZ"]}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "K5ABC" assert html =~ "K7XYZ" end test "displays multiple callsigns comma-separated", %{conn: conn} do insert_spot(%{sender_callsigns: ["K5ABC", "W1AW"], receiver_callsigns: ["K7XYZ"]}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "K5ABC, W1AW" end test "shows dash for empty callsigns", %{conn: conn} do insert_spot(%{sender_callsigns: [], receiver_callsigns: []}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "—" end test "displays modes", %{conn: conn} do insert_spot(%{modes: ["FT8", "FT4"]}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") assert html =~ "FT8" assert html =~ "FT4" end test "shows most recent spots first", %{conn: conn} do t1 = ~U[2026-07-01 12:00:00Z] t2 = ~U[2026-07-01 13:00:00Z] insert_spot(%{sender_grid: "EARLIER", last_spot_at: t1, hour_utc: t1}) insert_spot(%{sender_grid: "LATER", last_spot_at: t2, hour_utc: t2}) {:ok, _lv, html} = live(conn, ~p"/pskreporter") # LATER should appear before EARLIER in the HTML assert String.contains?(html, "LATER") assert String.contains?(html, "EARLIER") assert String.index(html, "LATER") < String.index(html, "EARLIER") end test "limits to 100 most recent spots", %{conn: conn} do # Insert 105 spots — only the 100 most recent should render for i <- 1..105 do t = DateTime.add!(now(), i - 105, :second) insert_spot(%{sender_grid: "GRID#{i}", last_spot_at: t, hour_utc: t}) end {:ok, _lv, html} = live(conn, ~p"/pskreporter") # The oldest 5 should be excluded (GRID1–GRID5 have the earliest timestamps) refute html =~ "GRID1" refute html =~ "GRID2" refute html =~ "GRID3" refute html =~ "GRID4" refute html =~ "GRID5" # The most recent 100 should be present assert html =~ "GRID105" assert html =~ "GRID6" end end describe "band filtering" do test "filtering by band shows only that band's spots", %{conn: conn} do insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()}) insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()}) {:ok, lv, _html} = live(conn, ~p"/pskreporter") # Both bands visible initially html = render(lv) assert html =~ "GRID2M" assert html =~ "GRID70" # Click the 2m badge html = lv |> element("button[phx-value-band=\"2m\"]") |> render_click() assert html =~ "GRID2M" refute html =~ "GRID70" # Filter badge should be highlighted assert html =~ "bg-primary/15" end test "clicking the active band again clears the filter", %{conn: conn} do insert_spot(%{band: "2m", sender_grid: "GRID2M", last_spot_at: now()}) insert_spot(%{band: "70cm", sender_grid: "GRID70", last_spot_at: now()}) {:ok, lv, _html} = live(conn, ~p"/pskreporter") # Filter to 2m html = lv |> element("button[phx-value-band=\"2m\"]") |> render_click() refute html =~ "GRID70" # Click 2m again to clear html = lv |> element("button[phx-value-band=\"2m\"]") |> render_click() assert html =~ "GRID2M" assert html =~ "GRID70" end test "clear filter button appears when filter is active", %{conn: conn} do insert_spot(%{band: "2m", last_spot_at: now()}) {:ok, lv, _html} = live(conn, ~p"/pskreporter") # No clear filter button initially refute render(lv) =~ "Clear filter" # Filter by band html = lv |> element("button[phx-value-band=\"2m\"]") |> render_click() assert html =~ "Clear filter" # Click clear filter html = lv |> element("button", "Clear filter") |> render_click() refute html =~ "Clear filter" end test "subtitle reflects active filter", %{conn: conn} do insert_spot(%{band: "70cm", last_spot_at: now()}) {:ok, lv, html} = live(conn, ~p"/pskreporter") assert html =~ "from the MQTT firehose" html = lv |> element("button[phx-value-band=\"70cm\"]") |> render_click() assert html =~ "70cm" refute html =~ "from the MQTT firehose" end test "empty state shows band name when filtered", %{conn: conn} do insert_spot(%{band: "2m", last_spot_at: now()}) {:ok, lv, _html} = live(conn, ~p"/pskreporter") html = lv |> element("button[phx-value-band=\"70cm\"]") |> render_click() assert html =~ "No spots found for band 70cm" end end end