prop/test/microwaveprop_web/live/pskr_spots_live_test.exs
Graham McInitre b65d3227cd feat: clickable band filters on PSK Reporter tab, fix all test & credo issues
PSK Reporter tab:
- Fix handle_info(:refresh_spots) to use start_async instead of blocking
- Add require Logger to fix compiler warning
- Make band counts clickable: clicking a band filters table to that band's
  last 100 spots; clicking again clears the filter
- Highlight active filter badge, show Clear filter link when filtered
- Update subtitle and empty state dynamically based on filter
- Filter persists across auto-refresh (60s)
- Fix empty state condition to render immediately without async guard

Credo fixes (mix credo --strict now passes):
- weather_map_component.ex: replace @doc false with @impl true (missing spec)
- contact_weather_enqueue_worker.ex: extract hrrr_placeholder_for_contact/3
  to reduce nesting depth
- profile_lookup.ex: replace MapSet+then+reject pattern with Enum.filter
  to fix both nesting depth and cyclomatic complexity

Test infrastructure fixes:
- Fix Release.migrate/0 to handle repos without migration directories
  (AprsRepo in test has no migrations)
- Fix insert_spot helper: add missing 19th param for inserted_at/updated_at
- Fix String.index/2 -> :binary.match for Elixir 1.20 compat
- Fix DateTime.add!/3 -> DateTime.add/3 for Elixir 1.20 compat
- Fix substring matching in limit test (GRID1 matched GRID10)
- Add Process.sleep+render calls for async band_counts in new tests
- Replace impossible 'empty state for filtered band' test with
  'switching band filter updates table' test
- All 19 tests pass, mix credo --strict clean
2026-07-20 13:01:00 -05:00

328 lines
9.1 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 =~ "-512 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 =~ "33 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