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), 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") Process.sleep(50) html = render(lv) 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") {l_pos, _} = :binary.match(html, "LATER") {e_pos, _} = :binary.match(html, "EARLIER") assert l_pos < e_pos end test "limits to 100 most recent spots", %{conn: conn} do # Insert 105 spots — only the 100 most recent should render # Pad single-digit grid numbers so substring matching is unambiguous for i <- 1..105 do t = DateTime.add(now(), i - 105, :second) grid = if i < 10, do: "GRID00#{i}", else: "GRID#{i}" insert_spot(%{sender_grid: grid, last_spot_at: t, hour_utc: t}) end {:ok, _lv, html} = live(conn, ~p"/pskreporter") # The oldest 5 should be excluded refute html =~ "GRID001" refute html =~ "GRID002" refute html =~ "GRID003" refute html =~ "GRID004" refute html =~ "GRID005" # The most recent 100 should be present assert html =~ "GRID105" assert html =~ "GRID006" 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") # Wait for async band_counts to complete, then re-render Process.sleep(100) html = render(lv) # Both bands visible in table 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") Process.sleep(100) _html = render(lv) # 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") Process.sleep(100) html = render(lv) # No clear filter button initially refute html =~ "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") Process.sleep(100) html = render(lv) 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 "switching band filter updates table", %{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") Process.sleep(100) _html = render(lv) # Filter to 2m html = lv |> element("button[phx-value-band=\"2m\"]") |> render_click() refute html =~ "GRID70" # Switch to 70cm html = lv |> element("button[phx-value-band=\"70cm\"]") |> render_click() refute html =~ "GRID2M" assert html =~ "GRID70" end end end