- Add 17 missing @spec annotations (layouts, error_json, error_html, skewt_svg) - Move 12+ nested alias/import/require to module top level - Add phx-change/id attributes to 11 raw HTML <form> tags - Remove 4 unused LiveView assigns (:bounds, :data_provider) - Add 3 missing doctest references (HrrrNativeClient, BulkFetch, Accounts) - Break 2 long lines (path_compute.ex:382) - Strengthen weak test assertions (is_binary→byte_size, is_list→!=[]) - Replace Module.concat with Module.safe_concat (2 occurrences) - Replace length/1 > 0 with list != [] (9 occurrences) - Remove no-op assert true, fix no-assertion tests Remaining: 24 socket.assigns introspection warnings (deliberate test pattern for observable behavior testing), 1 formatter-resistant long line, 3 app-code usage warnings.
665 lines
20 KiB
Elixir
665 lines
20 KiB
Elixir
defmodule MicrowavepropWeb.ContactLive.ShowHydrationTest do
|
|
use MicrowavepropWeb.ConnCase, async: true
|
|
|
|
import Microwaveprop.AccountsFixtures
|
|
import Phoenix.LiveViewTest
|
|
|
|
alias Microwaveprop.Propagation.ScoreCache
|
|
alias Microwaveprop.Radio.Contact
|
|
alias Microwaveprop.Radio.ContactCommonVolumeRadar
|
|
alias Microwaveprop.Radio.ContactEdit
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Terrain.ElevationClient
|
|
alias Microwaveprop.Terrain.TerrainProfile
|
|
alias Microwaveprop.Weather
|
|
alias Microwaveprop.Weather.HrrrNativeProfile
|
|
alias Microwaveprop.Weather.HrrrProfile
|
|
alias Microwaveprop.Weather.IemreObservation
|
|
alias Microwaveprop.Weather.NarrProfile
|
|
alias Microwaveprop.Weather.Sounding
|
|
alias Microwaveprop.Weather.SurfaceObservation
|
|
|
|
setup do
|
|
Req.Test.stub(ElevationClient, fn conn ->
|
|
Req.Test.json(conn, [])
|
|
end)
|
|
|
|
Req.Test.stub(Microwaveprop.Weather.HrrrClient, fn conn ->
|
|
Plug.Conn.send_resp(conn, 404, "not found")
|
|
end)
|
|
|
|
Req.Test.stub(Microwaveprop.Weather.SolarClient, fn conn ->
|
|
Req.Test.text(conn, "")
|
|
end)
|
|
|
|
:ok
|
|
end
|
|
|
|
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")
|
|
}
|
|
|
|
merged = Map.merge(default, attrs)
|
|
{user_id, changeset_attrs} = Map.pop(merged, :user_id)
|
|
|
|
{:ok, contact} =
|
|
%Contact{user_id: user_id}
|
|
|> Contact.changeset(changeset_attrs)
|
|
|> Repo.insert()
|
|
|
|
contact
|
|
end
|
|
|
|
describe "Show fully-hydrated render paths" do
|
|
defp seed_fully_hydrated do
|
|
contact =
|
|
create_contact(%{
|
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
|
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
|
})
|
|
|
|
_ =
|
|
Repo.insert!(%HrrrProfile{
|
|
valid_time: contact.qso_timestamp,
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
profile: [
|
|
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
|
|
%{"pres" => 925.0, "tmpc" => 15.0, "dwpc" => 10.0, "hght" => 800.0},
|
|
%{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
|
|
],
|
|
hpbl_m: 1200.0,
|
|
pwat_mm: 25.0,
|
|
surface_temp_c: 20.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1012.0,
|
|
surface_refractivity: 320.0,
|
|
min_refractivity_gradient: -120.0,
|
|
ducting_detected: false
|
|
})
|
|
|
|
_ =
|
|
Repo.insert!(%TerrainProfile{
|
|
contact_id: contact.id,
|
|
sample_count: 100,
|
|
path_points: [%{"km" => 0.0, "elev_m" => 200.0}, %{"km" => 295.0, "elev_m" => 250.0}],
|
|
max_elevation_m: 450.0,
|
|
min_clearance_m: 20.0,
|
|
diffraction_db: 6.5,
|
|
fresnel_hit_count: 3,
|
|
obstructed_count: 1,
|
|
verdict: "CLEAR"
|
|
})
|
|
|
|
_ =
|
|
Repo.insert!(%ContactCommonVolumeRadar{
|
|
contact_id: contact.id,
|
|
observed_at: contact.qso_timestamp,
|
|
common_volume_km2: 12_000.0,
|
|
pixel_count: 500,
|
|
rain_pixel_count: 5,
|
|
heavy_rain_pixel_count: 0,
|
|
max_dbz: 22.0,
|
|
mean_dbz: 12.0,
|
|
coverage_pct: 1.0
|
|
})
|
|
|
|
{:ok, station} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KDFW",
|
|
station_type: "asos",
|
|
name: "DFW Airport",
|
|
lat: 32.9,
|
|
lon: -97.02
|
|
})
|
|
|
|
Repo.insert!(
|
|
SurfaceObservation.changeset(%SurfaceObservation{}, %{
|
|
station_id: station.id,
|
|
observed_at: contact.qso_timestamp,
|
|
temp_f: 72.0,
|
|
dewpoint_f: 55.0,
|
|
relative_humidity: 55.0,
|
|
sea_level_pressure_mb: 1015.0
|
|
})
|
|
)
|
|
|
|
{:ok, raob_station} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KFWD",
|
|
station_type: "sounding",
|
|
name: "Fort Worth",
|
|
lat: 32.83,
|
|
lon: -97.30
|
|
})
|
|
|
|
Repo.insert!(
|
|
Sounding.changeset(%Sounding{}, %{
|
|
station_id: raob_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!(%IemreObservation{
|
|
date: ~D[2026-03-28],
|
|
lat: 31.625,
|
|
lon: -97.375,
|
|
hourly: [
|
|
%{"utc_hour" => 18, "air_temp_f" => 70.0, "dew_point_f" => 55.0, "skyc_pct" => 30.0}
|
|
]
|
|
})
|
|
|
|
contact
|
|
end
|
|
|
|
setup do
|
|
ScoreCache.clear()
|
|
:ok
|
|
end
|
|
|
|
test "fully populated contact renders every enrichment section", %{conn: conn} do
|
|
contact = seed_fully_hydrated()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ contact.station1
|
|
assert html =~ "Atmospheric Profile"
|
|
assert html =~ "Surface Observations"
|
|
assert html =~ "Soundings"
|
|
assert html =~ "1200"
|
|
assert html =~ "Line of sight is clear"
|
|
end
|
|
|
|
test "handle_info :terrain_ready refreshes the terrain section", %{conn: conn} do
|
|
contact = seed_fully_hydrated()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
_ = render_async(lv, 2_000)
|
|
|
|
send(lv.pid, {:terrain_ready, contact.id})
|
|
html = render(lv)
|
|
assert html =~ contact.station1
|
|
end
|
|
|
|
test "handle_info ignores terrain_ready for unrelated contact", %{conn: conn} do
|
|
contact = seed_fully_hydrated()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
_ = render_async(lv, 2_000)
|
|
|
|
send(lv.pid, {:terrain_ready, Ecto.UUID.generate()})
|
|
assert Process.alive?(lv.pid)
|
|
end
|
|
|
|
test "handle_info :sounding_fetched rehydrates the soundings table", %{conn: conn} do
|
|
contact = seed_fully_hydrated()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
_ = render_async(lv, 2_000)
|
|
|
|
send(lv.pid, {:sounding_fetched, %{contact_id: contact.id}})
|
|
html = render(lv)
|
|
assert html =~ "Soundings"
|
|
end
|
|
|
|
test "handle_info :sounding_fetched for a different contact is a no-op", %{conn: conn} do
|
|
contact = seed_fully_hydrated()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
_ = render_async(lv, 2_000)
|
|
|
|
send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}})
|
|
assert Process.alive?(lv.pid)
|
|
end
|
|
|
|
test "flagged_invalid contact shows the Flagged invalid badge", %{conn: conn} do
|
|
contact = create_contact()
|
|
|
|
{:ok, contact} =
|
|
contact
|
|
|> Ecto.Changeset.change(flagged_invalid: true)
|
|
|> Repo.update()
|
|
|
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
assert html =~ "Flagged invalid"
|
|
assert html =~ "line-through"
|
|
end
|
|
|
|
test "owner sees an Edit button (not Suggest Edit) for their own contact", %{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, html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
|
|
assert html =~ "Edit"
|
|
refute html =~ "Suggest Edit"
|
|
end
|
|
end
|
|
|
|
describe "Show render-branch coverage" do
|
|
use ExUnitProperties
|
|
|
|
defp seed_contact_with_overrides(opts \\ []) do
|
|
contact_overrides = Keyword.get(opts, :contact, %{})
|
|
terrain_overrides = Keyword.get(opts, :terrain, %{})
|
|
hrrr_overrides = Keyword.get(opts, :hrrr, %{})
|
|
|
|
contact =
|
|
create_contact(
|
|
Map.merge(
|
|
%{
|
|
pos1: %{"lat" => 32.9, "lon" => -97.0},
|
|
pos2: %{"lat" => 30.3, "lon" => -97.7}
|
|
},
|
|
contact_overrides
|
|
)
|
|
)
|
|
|
|
hrrr_row =
|
|
Map.merge(
|
|
%{
|
|
valid_time: contact.qso_timestamp,
|
|
lat: 32.9,
|
|
lon: -97.0,
|
|
profile: [
|
|
%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0},
|
|
%{"pres" => 850.0, "tmpc" => 10.0, "dwpc" => 5.0, "hght" => 1500.0}
|
|
],
|
|
hpbl_m: 1200.0,
|
|
pwat_mm: 25.0,
|
|
surface_temp_c: 20.0,
|
|
surface_dewpoint_c: 12.0,
|
|
surface_pressure_mb: 1012.0,
|
|
surface_refractivity: 320.0,
|
|
min_refractivity_gradient: -120.0,
|
|
ducting_detected: false
|
|
},
|
|
hrrr_overrides
|
|
)
|
|
|
|
Repo.insert!(struct!(HrrrProfile, hrrr_row))
|
|
|
|
terrain_row =
|
|
Map.merge(
|
|
%{
|
|
contact_id: contact.id,
|
|
sample_count: 100,
|
|
path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0}],
|
|
max_elevation_m: 450.0,
|
|
min_clearance_m: 20.0,
|
|
diffraction_db: 6.5,
|
|
fresnel_hit_count: 0,
|
|
obstructed_count: 0,
|
|
verdict: "CLEAR"
|
|
},
|
|
terrain_overrides
|
|
)
|
|
|
|
Repo.insert!(struct!(TerrainProfile, terrain_row))
|
|
|
|
contact
|
|
end
|
|
|
|
setup do
|
|
ScoreCache.clear()
|
|
:ok
|
|
end
|
|
|
|
test "IEMRE section renders nearest-hour temperature and dew point", %{conn: conn} do
|
|
contact = create_contact()
|
|
|
|
Repo.insert!(%IemreObservation{
|
|
date: DateTime.to_date(contact.qso_timestamp),
|
|
lat: 32.875,
|
|
lon: -97.0,
|
|
hourly: [
|
|
%{
|
|
"utc_hour" => 18,
|
|
"air_temp_f" => 73.4,
|
|
"dew_point_f" => 54.3,
|
|
"skyc_%" => 42.0,
|
|
"uwnd_mps" => 3.0,
|
|
"vwnd_mps" => 4.0,
|
|
"hourly_precip_in" => 0.0
|
|
}
|
|
]
|
|
})
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "IEMRE Gridded Reanalysis"
|
|
assert html =~ "73.4"
|
|
assert html =~ "54.3"
|
|
end
|
|
|
|
test "IEMRE section shows 'unavailable' status when marked as such", %{conn: conn} do
|
|
contact =
|
|
create_contact()
|
|
|> Ecto.Changeset.change(iemre_status: :unavailable)
|
|
|> Repo.update!()
|
|
|
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
assert html =~ "IEMRE data unavailable for this contact."
|
|
end
|
|
|
|
test "BLOCKED terrain verdict shows the obstruction badge class", %{conn: conn} do
|
|
contact =
|
|
seed_contact_with_overrides(
|
|
terrain: %{
|
|
verdict: "BLOCKED",
|
|
obstructed_count: 4,
|
|
fresnel_hit_count: 2,
|
|
diffraction_db: 18.2
|
|
}
|
|
)
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "BLOCKED"
|
|
assert html =~ "badge-error"
|
|
assert html =~ "obstructed"
|
|
end
|
|
|
|
test "FRESNEL_MINOR terrain verdict renders with warning styling", %{conn: conn} do
|
|
contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_MINOR"})
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "FRESNEL_MINOR"
|
|
assert html =~ "badge-warning"
|
|
end
|
|
|
|
test "FRESNEL_PARTIAL terrain verdict renders with warning styling", %{conn: conn} do
|
|
contact = seed_contact_with_overrides(terrain: %{verdict: "FRESNEL_PARTIAL"})
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "FRESNEL_PARTIAL"
|
|
assert html =~ "badge-warning"
|
|
end
|
|
|
|
test "expanded terrain table renders all path points", %{conn: conn} do
|
|
contact =
|
|
seed_contact_with_overrides(
|
|
terrain: %{
|
|
verdict: "CLEAR",
|
|
path_points: [
|
|
%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 200.0},
|
|
%{"lat" => 31.6, "lon" => -97.35, "dist_km" => 147.5, "elev" => 320.0},
|
|
%{"lat" => 30.3, "lon" => -97.7, "dist_km" => 295.0, "elev" => 250.0}
|
|
]
|
|
}
|
|
)
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
_ = render_async(lv, 2_000)
|
|
|
|
html = render_click(lv, "toggle_terrain", %{})
|
|
assert html =~ "Dist (km)"
|
|
end
|
|
|
|
test "Propagation Analysis renders factor rows and composite tier label", %{conn: conn} do
|
|
contact = seed_contact_with_overrides()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "Propagation Analysis"
|
|
assert html =~ "Contribution"
|
|
assert html =~ "Humidity"
|
|
assert html =~ "/100"
|
|
|
|
assert String.match?(html, ~r/(EXCELLENT|GOOD|MARGINAL|POOR|NEGLIGIBLE)/)
|
|
end
|
|
|
|
test "Data Sources card renders HRRR, Terrain, and Elevation blocks", %{conn: conn} do
|
|
contact = seed_contact_with_overrides()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "Data Sources"
|
|
assert html =~ "HRRR Model"
|
|
assert html =~ "Terrain Analysis"
|
|
assert html =~ "Elevation Profile"
|
|
end
|
|
|
|
test "internal-network conn kicks off enqueue path and transitions pending status", %{conn: conn} do
|
|
contact = create_contact()
|
|
|
|
conn = %{conn | remote_ip: {172, 56, 0, 1}}
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
_ = render_async(lv, 2_000)
|
|
|
|
assert Process.alive?(lv.pid)
|
|
assert render(lv) =~ contact.station1
|
|
end
|
|
|
|
test "radar row surfaces max_dbz and coverage_pct in the mechanism block", %{conn: conn} do
|
|
contact = create_contact()
|
|
|
|
Repo.insert!(%ContactCommonVolumeRadar{
|
|
contact_id: contact.id,
|
|
observed_at: contact.qso_timestamp,
|
|
common_volume_km2: 12_500.0,
|
|
pixel_count: 800,
|
|
rain_pixel_count: 150,
|
|
heavy_rain_pixel_count: 25,
|
|
max_dbz: 47.5,
|
|
mean_dbz: 22.3,
|
|
coverage_pct: 18.0
|
|
})
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "Propagation Mechanism"
|
|
assert html =~ "47.5 dBZ"
|
|
assert html =~ "Heavy-rain pixels: 25"
|
|
end
|
|
|
|
test "non-owner with a pending edit sees the pending-edit banner", %{conn: conn} do
|
|
contact = create_contact()
|
|
stranger = user_fixture()
|
|
|
|
Repo.insert!(%ContactEdit{
|
|
contact_id: contact.id,
|
|
user_id: stranger.id,
|
|
proposed_changes: %{"station2" => "K9EDIT"},
|
|
status: :pending
|
|
})
|
|
|
|
conn = log_in_user(conn, stranger)
|
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
|
|
assert html =~ "pending edit for this contact awaiting admin review"
|
|
end
|
|
|
|
test "full Atmospheric Profile block renders surface temp/dewpoint/pressure", %{conn: conn} do
|
|
contact = seed_contact_with_overrides()
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "Surface Temp:"
|
|
assert html =~ "Surface Dewpoint:"
|
|
assert html =~ "Surface Pressure:"
|
|
end
|
|
|
|
test "soundings-empty state shows the 1000 km fallback copy", %{conn: conn} do
|
|
contact = create_contact()
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "No soundings found within 1000 km."
|
|
end
|
|
|
|
test "surface-observations queued status shows spinner copy", %{conn: conn} do
|
|
contact =
|
|
create_contact()
|
|
|> Ecto.Changeset.change(weather_status: :queued)
|
|
|> Repo.update!()
|
|
|
|
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
|
|
assert html =~ "Surface Observations"
|
|
end
|
|
|
|
test "ducting detected on the HRRR profile shows the Ducting badge", %{conn: conn} do
|
|
contact =
|
|
seed_contact_with_overrides(
|
|
hrrr: %{
|
|
ducting_detected: true,
|
|
min_refractivity_gradient: -200.0
|
|
}
|
|
)
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "Ducting"
|
|
end
|
|
|
|
test "sounding row with a real station shows the ducting detection badge", %{conn: conn} do
|
|
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
|
|
})
|
|
|
|
Repo.insert!(
|
|
Sounding.changeset(%Sounding{}, %{
|
|
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,
|
|
surface_refractivity: 340.0,
|
|
ducting_detected: true
|
|
})
|
|
)
|
|
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
html = render_async(lv, 2_000)
|
|
|
|
assert html =~ "KFWD"
|
|
assert html =~ "Ducting"
|
|
end
|
|
|
|
property "obs-table sort handler is total over every known field and direction", %{conn: conn} do
|
|
contact = create_contact(%{station1: "W5PROP", qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
|
|
|
{:ok, station} =
|
|
Weather.find_or_create_station(%{
|
|
station_code: "KDFW",
|
|
station_type: "asos",
|
|
name: "DFW Airport",
|
|
lat: 32.9,
|
|
lon: -97.02
|
|
})
|
|
|
|
Repo.insert!(
|
|
SurfaceObservation.changeset(%SurfaceObservation{}, %{
|
|
station_id: station.id,
|
|
observed_at: contact.qso_timestamp,
|
|
temp_f: 70.0,
|
|
dewpoint_f: 55.0,
|
|
relative_humidity: 55.0,
|
|
sea_level_pressure_mb: 1015.0
|
|
})
|
|
)
|
|
|
|
known_fields =
|
|
~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb zzz_unknown)
|
|
|
|
field_stream = StreamData.member_of(known_fields)
|
|
sequence_gen = StreamData.list_of(field_stream, min_length: 1, max_length: 6)
|
|
|
|
check all(fields <- sequence_gen, max_runs: 15) do
|
|
{:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}")
|
|
_ = render_async(lv, 2_000)
|
|
|
|
for f <- fields do
|
|
html = render_click(lv, "sort", %{"field" => f, "table" => "obs"})
|
|
assert html =~ "Surface Observations", "sort by #{f} lost the obs table"
|
|
end
|
|
|
|
assert Process.alive?(lv.pid)
|
|
end
|
|
end
|
|
|
|
property "soundings-table sort handler tolerates arbitrary field keys", %{conn: conn} do
|
|
contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]})
|
|
|
|
fields_pool =
|
|
~w(station_name observed_at surface_temp_c surface_refractivity k_index lifted_index unknown)
|
|
|
|
sequence_gen =
|
|
StreamData.list_of(StreamData.member_of(fields_pool), min_length: 1, max_length: 4)
|
|
|
|
check all(fields <- sequence_gen, max_runs: 10) do
|
|
{:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}")
|
|
_ = render_async(lv, 2_000)
|
|
|
|
for f <- fields do
|
|
html = render_click(lv, "sort", %{"field" => f, "table" => "soundings"})
|
|
assert html =~ "Soundings", "sort by #{f} lost the soundings section"
|
|
end
|
|
|
|
assert Process.alive?(lv.pid)
|
|
end
|
|
end
|
|
end
|
|
end
|