defmodule MicrowavepropWeb.ContactLive.ShowCoverageTest do use MicrowavepropWeb.ConnCase, async: true import Phoenix.LiveViewTest alias Microwaveprop.Radio.Contact alias Microwaveprop.Repo alias Microwaveprop.Terrain.ElevationClient alias Microwaveprop.Weather.HrrrNativeProfile alias Microwaveprop.Weather.NarrProfile 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 additional branch coverage" do use ExUnitProperties import Microwaveprop.AccountsFixtures alias Microwaveprop.Propagation.BandConfig alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Propagation.Scorer alias Microwaveprop.Radio.ContactCommonVolumeRadar alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Terrain.TerrainProfile alias Microwaveprop.Weather.HrrrProfile defp admin_fixture! do user = user_fixture() {:ok, user} = Microwaveprop.Accounts.admin_update_user(user, %{is_admin: true}) user end setup do ScoreCache.clear() :ok end test "factor notes render every note-branch for a 10 GHz contact", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("10000"), pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, qso_timestamp: ~U[2026-03-28 18:00:00Z] }) Repo.insert!(%HrrrProfile{ valid_time: contact.qso_timestamp, lat: 32.9, lon: -97.0, profile: [ %{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 12.0, "hght" => 100.0} ], hpbl_m: 1200.0, pwat_mm: 32.0, surface_temp_c: 22.0, surface_dewpoint_c: 12.0, surface_pressure_mb: 1020.0, surface_refractivity: 340.0, min_refractivity_gradient: -160.0, ducting_detected: true }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "beneficial at this band" assert html =~ "ducting" assert html =~ "(high)" assert html =~ "summer peak band" assert html =~ "refractivity aid" assert html =~ "None" end test "factor notes render 'absorption' and 'winter peak' branches at 24 GHz", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("24000"), pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, qso_timestamp: ~U[2026-01-15 18:00:00Z] }) Repo.insert!(%HrrrProfile{ valid_time: contact.qso_timestamp, lat: 32.9, lon: -97.0, profile: [ %{"pres" => 1000.0, "tmpc" => 5.0, "dwpc" => -2.0, "hght" => 100.0} ], hpbl_m: 1200.0, pwat_mm: 28.0, surface_temp_c: 5.0, surface_dewpoint_c: -2.0, surface_pressure_mb: 1000.0, surface_refractivity: 310.0, min_refractivity_gradient: -50.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "harmful at this band" assert html =~ "absorption" assert html =~ "winter peak band" assert html =~ "(low)" end test "td_note renders 'near saturation' when dewpoint hugs temperature", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("10000"), 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" => 19.5, "hght" => 100.0}], hpbl_m: 800.0, pwat_mm: 25.0, surface_temp_c: 20.0, surface_dewpoint_c: 19.5, surface_pressure_mb: 1010.0, surface_refractivity: 340.0, min_refractivity_gradient: -90.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "near saturation" assert html =~ "enhanced" assert html =~ "(moderate)" end test "td_note renders 'dry' branch for a large T-Td depression", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("10000"), 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" => 30.0, "dwpc" => 0.0, "hght" => 100.0}], hpbl_m: 2200.0, pwat_mm: 8.0, surface_temp_c: 30.0, surface_dewpoint_c: 0.0, surface_pressure_mb: 1012.0, surface_refractivity: 280.0, min_refractivity_gradient: -40.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "(dry)" assert html =~ "(normal)" end test "BLOCKED terrain with ducting selects the 'atmospheric ducting' mechanism copy", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("10000"), 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" => 15.0, "hght" => 100.0}], hpbl_m: 800.0, pwat_mm: 30.0, surface_temp_c: 20.0, surface_dewpoint_c: 15.0, surface_pressure_mb: 1015.0, surface_refractivity: 340.0, min_refractivity_gradient: -180.0, ducting_detected: true }) Repo.insert!(%TerrainProfile{ 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: 800.0, min_clearance_m: -50.0, diffraction_db: 22.0, fresnel_hit_count: 5, obstructed_count: 4, verdict: "BLOCKED" }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "tropospheric duct" or html =~ "atmospheric ducting" end test "BLOCKED terrain with enhanced refraction but no ducting uses the 'dN/dh' copy", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("10000"), 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" => 10.0, "hght" => 100.0}], hpbl_m: 1200.0, pwat_mm: 20.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1015.0, surface_refractivity: 320.0, min_refractivity_gradient: -140.0, ducting_detected: false }) Repo.insert!(%TerrainProfile{ 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: 800.0, min_clearance_m: -20.0, diffraction_db: 18.0, fresnel_hit_count: 3, obstructed_count: 2, verdict: "BLOCKED" }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "dN/dh =" assert html =~ "Enhanced refraction" end test "BLOCKED terrain with no ducting or refraction falls back to diffraction copy", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("10000"), 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" => 10.0, "hght" => 100.0}], hpbl_m: 1200.0, pwat_mm: 20.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1015.0, surface_refractivity: 320.0, min_refractivity_gradient: -30.0, ducting_detected: false }) Repo.insert!(%TerrainProfile{ 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: 800.0, min_clearance_m: -20.0, diffraction_db: 14.5, fresnel_hit_count: 3, obstructed_count: 2, verdict: "BLOCKED" }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "diffraction loss" assert html =~ "knife-edge diffraction" end test "clear path with long distance + ducting renders 'extending range' copy", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("10000"), pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, distance_km: Decimal.new("295") }) Repo.insert!(%HrrrProfile{ valid_time: contact.qso_timestamp, lat: 32.9, lon: -97.0, profile: [%{"pres" => 1000.0, "tmpc" => 22.0, "dwpc" => 20.0, "hght" => 100.0}], hpbl_m: 700.0, pwat_mm: 30.0, surface_temp_c: 22.0, surface_dewpoint_c: 20.0, surface_pressure_mb: 1012.0, surface_refractivity: 350.0, min_refractivity_gradient: -200.0, ducting_detected: true }) Repo.insert!(%TerrainProfile{ 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: 400.0, min_clearance_m: 50.0, diffraction_db: 0.0, fresnel_hit_count: 0, obstructed_count: 0, verdict: "CLEAR" }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "Ducting conditions present" assert html =~ "extended range" or html =~ "extending range" end test "clear path with enhanced refraction but no ducting hits the long-path branch", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("10000"), pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, distance_km: Decimal.new("200") }) Repo.insert!(%HrrrProfile{ valid_time: contact.qso_timestamp, lat: 32.9, lon: -97.0, profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 10.0, "hght" => 100.0}], hpbl_m: 1200.0, pwat_mm: 20.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1012.0, surface_refractivity: 320.0, min_refractivity_gradient: -140.0, ducting_detected: false }) Repo.insert!(%TerrainProfile{ 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: 400.0, min_clearance_m: 50.0, diffraction_db: 0.0, fresnel_hit_count: 0, obstructed_count: 0, verdict: "CLEAR" }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "Enhanced refraction present" end test "admin edit of private toggle and height triggers the diff branch", %{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_submit(lv, "submit_edit", %{ "edit" => %{ "station1" => contact.station1, "station2" => contact.station2, "grid1" => contact.grid1, "grid2" => contact.grid2, "mode" => contact.mode, "height1_ft" => "35", "private" => "true" } }) assert html =~ "Contact updated" refreshed = Repo.get!(Contact, contact.id) assert refreshed.height1_ft == 35 assert refreshed.private == true end test "admin edit with no changes flashes 'No changes detected'", %{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_submit(lv, "submit_edit", %{ "edit" => %{ "station1" => contact.station1, "station2" => contact.station2, "grid1" => contact.grid1, "grid2" => contact.grid2, "mode" => contact.mode } }) assert html =~ "No changes detected" end test "owner submit with identical values returns no-changes flash", %{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}") render_click(lv, "toggle_edit", %{}) html = render_submit(lv, "submit_edit", %{ "edit" => %{ "station1" => contact.station1, "station2" => contact.station2, "grid1" => contact.grid1, "grid2" => contact.grid2, "mode" => contact.mode } }) assert html =~ "No changes detected" end test "stranger submit with an invalid mode surfaces the changeset error flash", %{conn: conn} do contact = create_contact() stranger = user_fixture() conn = log_in_user(conn, stranger) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") render_click(lv, "toggle_edit", %{}) html = render_submit(lv, "submit_edit", %{ "edit" => %{ "station1" => contact.station1, "station2" => contact.station2, "grid1" => contact.grid1, "grid2" => contact.grid2, "mode" => "BOGUS" } }) assert html =~ "Could not submit edit" assert Repo.aggregate(ContactEdit, :count, :id) == 0 end test "internal_network? false when no session IP is recorded", %{conn: conn} do contact = create_contact() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) refute :sys.get_state(lv.pid).socket.assigns.can_enqueue end test "internal_network? false for an IP just outside the enqueue subnet", %{conn: conn} do conn = %{conn | remote_ip: {8, 8, 8, 8}} contact = create_contact() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) refute :sys.get_state(lv.pid).socket.assigns.can_enqueue end test "internal_network? true when session IP sits inside the enqueue subnet", %{conn: conn} do conn = %{conn | remote_ip: {172, 56, 0, 10}} contact = create_contact() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) assert :sys.get_state(lv.pid).socket.assigns.can_enqueue end test "expanded_soundings MapSet exposes the skew-T table when a sounding is expanded", %{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{} |> 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}") _ = render_async(lv, 2_000) html_open = render_click(lv, "toggle_profile", %{"id" => sounding.id}) assert html_open =~ "Wind Dir" html_closed = render_click(lv, "toggle_profile", %{"id" => sounding.id}) refute html_closed =~ "Wind Dir" end test "load_pending_edit returns the existing edit for a logged-in user", %{conn: conn} do contact = create_contact() user = user_fixture() Repo.insert!(%ContactEdit{ contact_id: contact.id, user_id: user.id, proposed_changes: %{"station2" => "K9EDIT"}, status: :pending }) conn = log_in_user(conn, user) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") assigns = :sys.get_state(lv.pid).socket.assigns assert %ContactEdit{status: :pending} = assigns.pending_edit end test "fetch_queue_counts hits the Cache.fetch_or_store round trip", %{conn: conn} do alias Microwaveprop.Workers.SolarIndexWorker {:ok, _job} = Oban.insert(SolarIndexWorker.new(%{"date" => "2026-03-28"})) contact = create_contact() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") counts = :sys.get_state(lv.pid).socket.assigns.queue_counts assert is_map(counts) end test "handle_info({:sounding_fetched, …}) with mismatched contact_id is a no-op", %{conn: conn} do contact = create_contact() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) soundings_before = :sys.get_state(lv.pid).socket.assigns.soundings send(lv.pid, {:sounding_fetched, %{contact_id: Ecto.UUID.generate()}}) _ = :sys.get_state(lv.pid) assert Process.alive?(lv.pid) assert :sys.get_state(lv.pid).socket.assigns.soundings == soundings_before end test "rain_scatter mechanism surfaces for a 24 GHz contact with heavy rain pixels", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("24000"), distance_km: Decimal.new("250") }) Repo.insert!(%ContactCommonVolumeRadar{ contact_id: contact.id, observed_at: contact.qso_timestamp, common_volume_km2: 15_000.0, pixel_count: 1200, rain_pixel_count: 400, heavy_rain_pixel_count: 120, max_dbz: 52.0, mean_dbz: 35.0, coverage_pct: 33.0 }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "52.0 dBZ" assert html =~ "Heavy-rain pixels: 120" assert html =~ "Propagation Mechanism" end test "elevation profile hydrates when the SRTM stub returns a populated path", %{conn: conn} do Req.Test.stub(ElevationClient, fn conn -> if conn.host == "api.open-meteo.com" do n = 257 Req.Test.json(conn, %{"elevation" => List.duplicate(300.0, n)}) else Req.Test.json(conn, []) end end) contact = create_contact(%{ band: Decimal.new("10000"), 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" => 10.0, "hght" => 100.0}], hpbl_m: 1200.0, pwat_mm: 25.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1012.0, surface_refractivity: 320.0, min_refractivity_gradient: -200.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile assert ep assert is_list(ep.points) assert is_list(ep.ducts) end property "Scorer.composite_score always stays within [0, 100]" do all_band_mhz = BandConfig.all_freqs() check all( band_mhz <- StreamData.member_of(all_band_mhz), temp_c <- StreamData.float(min: -40.0, max: 50.0), td_depression <- StreamData.float(min: 0.0, max: 25.0), pressure_mb <- StreamData.float(min: 950.0, max: 1050.0), sky_pct <- StreamData.float(min: 0.0, max: 100.0), wind_kts <- StreamData.float(min: 0.0, max: 40.0), grad <- StreamData.float(min: -300.0, max: 50.0), pwat_mm <- StreamData.float(min: 0.0, max: 60.0), month <- StreamData.integer(1..12), hour <- StreamData.integer(0..23), max_runs: 30 ) do band_config = BandConfig.get(band_mhz) conditions = %{ temp_f: temp_c * 9 / 5 + 32, dewpoint_f: (temp_c - td_depression) * 9 / 5 + 32, temp_c: temp_c, dewpoint_c: temp_c - td_depression, abs_humidity: max(0.1, 10.0 - td_depression * 0.3), min_refractivity_gradient: grad, bl_depth_m: 1000.0, pressure_mb: pressure_mb, prev_pressure_mb: pressure_mb, sky_cover_pct: sky_pct, wind_speed_kts: wind_kts, rain_rate_mmhr: 0.0, pwat_mm: pwat_mm, month: month, utc_hour: hour, utc_minute: 0, latitude: 32.0, longitude: -97.0, best_duct_band_ghz: nil, bulk_richardson: nil } %{score: score} = Scorer.composite_score(conditions, band_config) assert is_integer(score) assert score >= 0 and score <= 100 end end property "obs-table sort handler round-trips for any known field", %{conn: conn} do contact = create_contact() fields_pool = StreamData.member_of(~w(observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)) check all(field <- fields_pool, max_runs: 12) do {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"}) _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) a1 = :sys.get_state(lv.pid).socket.assigns assert a1.obs_sort_by == field assert a1.obs_sort_order == "asc" _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) a2 = :sys.get_state(lv.pid).socket.assigns assert a2.obs_sort_order == "desc" _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) a3 = :sys.get_state(lv.pid).socket.assigns assert a3.obs_sort_order == "asc" end end end describe "Show failure-state + enrichment-combo render branches" do use ExUnitProperties import Microwaveprop.AccountsFixtures alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Radio.ContactCommonVolumeRadar alias Microwaveprop.Terrain.TerrainProfile alias Microwaveprop.Weather alias Microwaveprop.Weather.HrrrProfile alias Microwaveprop.Weather.SolarIndex alias Microwaveprop.Weather.Sounding alias Microwaveprop.Weather.SurfaceObservation setup do ScoreCache.clear() :ok end test "terrain_status :failed with no terrain row falls back to the no-profile copy", %{conn: conn} do contact = create_contact() |> Ecto.Changeset.change(terrain_status: :failed) |> Repo.update!() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "No terrain profile available." refute html =~ "Computing terrain profile" end test "iemre_status :failed shows the generic 'No IEMRE data' copy", %{conn: conn} do contact = create_contact() |> Ecto.Changeset.change(iemre_status: :failed) |> Repo.update!() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "No IEMRE data available." refute html =~ "Fetching IEMRE data" end test "weather_status :failed renders the empty surface-obs copy with no spinner", %{conn: conn} do contact = create_contact() |> Ecto.Changeset.change(weather_status: :failed) |> Repo.update!() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "No surface observations found nearby." end test "hrrr_status :unavailable + no NARR row shows the NARR-fallback spinner", %{conn: conn} do contact = create_contact() |> Ecto.Changeset.change(hrrr_status: :unavailable) |> Repo.update!() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "HRRR unavailable" assert html =~ "fetching NARR reanalysis" end test "NARR fallback with HRRR nil renders atmospheric profile from NARR", %{conn: conn} do contact = create_contact(%{ qso_timestamp: ~U[2010-06-15 18:00:00Z], pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7} }) Repo.insert!(%NarrProfile{ valid_time: ~U[2010-06-15 18:00:00Z], lat: 32.9, lon: -97.0, profile: [%{"pres" => 850.0, "tmpc" => 14.0, "dwpc" => 10.0, "hght" => 1500.0}], surface_temp_c: 24.0, surface_dewpoint_c: 18.0, surface_pressure_mb: 1012.0, hpbl_m: 1150.0, pwat_mm: 28.0, surface_refractivity: 318.0, min_refractivity_gradient: -55.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "NARR" assert html =~ "Surface Temp:" assert html =~ "Surface Dewpoint:" end test "radar row without HRRR still renders the mechanism block and radar summary", %{conn: conn} do contact = create_contact(%{band: Decimal.new("10000"), distance_km: Decimal.new("150")}) Repo.insert!(%ContactCommonVolumeRadar{ contact_id: contact.id, observed_at: contact.qso_timestamp, common_volume_km2: 14_000.0, pixel_count: 900, rain_pixel_count: 60, heavy_rain_pixel_count: 8, max_dbz: 38.0, mean_dbz: 19.0, coverage_pct: 6.0 }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "Propagation Mechanism" assert html =~ "38.0 dBZ" assert html =~ "Heavy-rain pixels: 8" assert html =~ "Radar coverage: 6%" end test "solar handle_async populates Solar Conditions with real SFI/Ap/Kp", %{conn: conn} do contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) Repo.insert!(%SolarIndex{ date: ~D[2026-03-28], sfi: 142.0, sunspot_number: 98, ap_index: 6, kp_values: [1.0, 2.0, 2.7, 3.0, 1.3, 0.7, 1.0, 1.3] }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "Solar Conditions" assert html =~ "142" assert html =~ "98" assert html =~ "2.7" end test "solar handle_async for a missing date in an internal subnet enqueues a fetch", %{conn: conn} do contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) conn = %{conn | remote_ip: {172, 56, 0, 50}} {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) assigns = :sys.get_state(lv.pid).socket.assigns assert assigns.solar == nil assert Process.alive?(lv.pid) end test "soundings-array is rendered alongside HRRR profile when both are present", %{conn: conn} 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}], hpbl_m: 1100.0, pwat_mm: 24.0, surface_temp_c: 20.0, surface_dewpoint_c: 12.0, surface_pressure_mb: 1012.0, surface_refractivity: 322.0, min_refractivity_gradient: -90.0, ducting_detected: false }) {: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" => 22.0, "dwpc" => 18.0, "hght" => 100.0}, %{"pres" => 950.0, "tmpc" => 24.0, "dwpc" => 5.0, "hght" => 600.0} ], level_count: 2, surface_temp_c: 22.0, surface_dewpoint_c: 18.0, surface_refractivity: 345.0, ducting_detected: true, duct_characteristics: [%{"base" => 100.0, "top" => 400.0, "strength" => 8.0}] }) ) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "HRRR" assert html =~ "KFWD" assert html =~ "345" end test "BLOCKED terrain without an elevation profile uses the generic obstructed copy", %{conn: conn} 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" => 10.0, "hght" => 100.0}], hpbl_m: 1200.0, pwat_mm: 22.0, surface_temp_c: 20.0, surface_dewpoint_c: 10.0, surface_pressure_mb: 1010.0, surface_refractivity: 320.0, min_refractivity_gradient: -50.0, ducting_detected: false }) Repo.insert!(%TerrainProfile{ 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: 800.0, min_clearance_m: -10.0, diffraction_db: 10.0, fresnel_hit_count: 1, obstructed_count: 1, verdict: "BLOCKED" }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "Path is terrain-obstructed" end test "FRESNEL_MINOR verdict via elevation profile drives the describe-verdict branch", %{conn: conn} do Req.Test.stub(ElevationClient, fn conn -> if conn.host == "api.open-meteo.com" do Req.Test.json(conn, %{"elevation" => List.duplicate(250.0, 257)}) else Req.Test.json(conn, []) end end) contact = create_contact(%{ pos1: %{"lat" => 32.9, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7} }) Repo.insert!(%TerrainProfile{ contact_id: contact.id, sample_count: 100, path_points: [%{"lat" => 32.9, "lon" => -97.0, "dist_km" => 0.0, "elev" => 250.0}], max_elevation_m: 260.0, min_clearance_m: 30.0, diffraction_db: 0.0, fresnel_hit_count: 0, obstructed_count: 0, verdict: "CLEAR" }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) ep = :sys.get_state(lv.pid).socket.assigns.elevation_profile if ep do assert is_binary(ep.verdict) end assert render(lv) =~ "Propagation Mechanism" end test "2304 MHz band with elevated PWAT hits the 10 GHz band_summary branch", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("2304"), 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" => 22.0, "dwpc" => 18.0, "hght" => 100.0}], hpbl_m: 1100.0, pwat_mm: 32.0, surface_temp_c: 22.0, surface_dewpoint_c: 18.0, surface_pressure_mb: 1012.0, surface_refractivity: 345.0, min_refractivity_gradient: -90.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "At 10 GHz, the elevated moisture" end test "47 GHz band with elevated PWAT hits the 24G-and-up absorption copy", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("47000"), 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" => 15.0, "hght" => 100.0}], hpbl_m: 1200.0, pwat_mm: 30.0, surface_temp_c: 20.0, surface_dewpoint_c: 15.0, surface_pressure_mb: 1012.0, surface_refractivity: 330.0, min_refractivity_gradient: -60.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "high moisture" assert html =~ "water vapor absorption" end test "144 MHz band falls through every band_summary clause (no moisture copy)", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("144"), 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" => 10.0, "dwpc" => -5.0, "hght" => 100.0}], hpbl_m: 800.0, pwat_mm: 8.0, surface_temp_c: 10.0, surface_dewpoint_c: -5.0, surface_pressure_mb: 1015.0, surface_refractivity: 290.0, min_refractivity_gradient: -30.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) refute html =~ "At 10 GHz, the elevated moisture" refute html =~ "water vapor absorption" assert html =~ "144 MHz" end test "1296 MHz band renders through the analysis without 10 GHz copy", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("1296"), 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" => 10.0, "dwpc" => 0.0, "hght" => 100.0}], hpbl_m: 800.0, pwat_mm: 10.0, surface_temp_c: 10.0, surface_dewpoint_c: 0.0, surface_pressure_mb: 1015.0, surface_refractivity: 300.0, min_refractivity_gradient: -40.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "1296 MHz" refute html =~ "At 10 GHz, the elevated moisture" end test "75 GHz + low PWAT skips the moisture narrative", %{conn: conn} do contact = create_contact(%{ band: Decimal.new("75000"), 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" => 5.0, "dwpc" => -15.0, "hght" => 100.0}], hpbl_m: 700.0, pwat_mm: 5.0, surface_temp_c: 5.0, surface_dewpoint_c: -15.0, surface_pressure_mb: 1020.0, surface_refractivity: 280.0, min_refractivity_gradient: -30.0, ducting_detected: false }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") html = render_async(lv, 2_000) assert html =~ "75 GHz" refute html =~ "water vapor absorption" end test "admin edit with proposed == %{} (no-op submit) surfaces 'No changes detected'", %{conn: conn} do contact = create_contact() admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!() 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" => contact.station1, "station2" => contact.station2, "grid1" => contact.grid1, "grid2" => contact.grid2, "mode" => contact.mode, "band" => Decimal.to_string(contact.band) } }) assert html =~ "No changes detected" end test "admin edit changing mode to FT8 applies the update", %{conn: conn} do contact = create_contact(%{mode: "CW"}) admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!() 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" => contact.station1, "station2" => contact.station2, "grid1" => contact.grid1, "grid2" => contact.grid2, "mode" => "FT8" } }) assert html =~ "Contact updated" assert Repo.get!(Contact, contact.id).mode == "FT8" end test "admin edit changing mode to SSB applies the update", %{conn: conn} do contact = create_contact(%{mode: "CW"}) admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!() 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" => contact.station1, "station2" => contact.station2, "grid1" => contact.grid1, "grid2" => contact.grid2, "mode" => "SSB" } }) assert Repo.get!(Contact, contact.id).mode == "SSB" end test "sort handler returns a sorted list of the same length for a seeded obs set", %{conn: conn} do contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) for code <- ~w(KDFW KDAL KAFW KGKY) do {:ok, station} = Weather.find_or_create_station(%{ station_code: code, station_type: "asos", name: "#{code} Airport", lat: 32.9, lon: -97.02 }) Repo.insert!( SurfaceObservation.changeset(%SurfaceObservation{}, %{ station_id: station.id, observed_at: contact.qso_timestamp, temp_f: 60.0 + :rand.uniform() * 20, dewpoint_f: 40.0 + :rand.uniform() * 20, relative_humidity: 50.0, sea_level_pressure_mb: 1014.0 }) ) end {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) before = length(:sys.get_state(lv.pid).socket.assigns.surface_observations) for f <- ~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb bogus_key) do html = render_click(lv, "sort", %{"field" => f, "table" => "obs"}) assert html =~ "Surface Observations" after_count = length(:sys.get_state(lv.pid).socket.assigns.surface_observations) assert after_count == before, "row count changed after sorting by #{f}" end end test "fetch_queue_counts returns a map after seeding multiple distinct queues", %{conn: conn} do alias Microwaveprop.Workers.SolarIndexWorker alias Microwaveprop.Workers.TerrainProfileWorker contact = create_contact() {:ok, _} = Oban.insert(SolarIndexWorker.new(%{"date" => Date.to_iso8601(~D[2026-03-28])})) {:ok, _} = Oban.insert(TerrainProfileWorker.new(%{"contact_id" => contact.id})) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") counts = :sys.get_state(lv.pid).socket.assigns.queue_counts assert is_map(counts) for {k, v} <- counts do assert is_binary(k) assert is_integer(v) and v >= 0 end end test "LV stays alive after render_async completes with no DB rows at all", %{conn: conn} do contact = create_contact() {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) assigns = :sys.get_state(lv.pid).socket.assigns assert Process.alive?(lv.pid) assert MapSet.size(assigns.hydration_pending) == 0 end property "propagation_mechanism-derived summary stays a binary across random flag combos", %{conn: conn} do bool_gen = StreamData.boolean() check all( ducting? <- bool_gen, enhanced? <- bool_gen, blocked? <- bool_gen, long_path? <- bool_gen, max_runs: 10 ) do dist = if long_path?, do: Decimal.new("200"), else: Decimal.new("50") nonce = System.unique_integer([:positive]) lat1 = 32.0 + nonce / 1_000_000 iter_valid_time = DateTime.add(~U[2026-03-28 18:00:00Z], nonce, :second) contact = create_contact(%{ band: Decimal.new("10000"), qso_timestamp: iter_valid_time, pos1: %{"lat" => lat1, "lon" => -97.0}, pos2: %{"lat" => 30.3, "lon" => -97.7}, distance_km: dist }) Repo.insert!(%HrrrProfile{ valid_time: contact.qso_timestamp, lat: lat1, lon: -97.0, profile: [%{"pres" => 1000.0, "tmpc" => 20.0, "dwpc" => 12.0, "hght" => 100.0}], hpbl_m: 1200.0, pwat_mm: 22.0, surface_temp_c: 20.0, surface_dewpoint_c: 12.0, surface_pressure_mb: 1012.0, surface_refractivity: 320.0, min_refractivity_gradient: if(enhanced?, do: -150.0, else: -40.0), ducting_detected: ducting? }) Repo.insert!(%TerrainProfile{ 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: if(blocked?, do: 800.0, else: 400.0), min_clearance_m: if(blocked?, do: -20.0, else: 40.0), diffraction_db: if(blocked?, do: 15.0, else: 0.0), fresnel_hit_count: 0, obstructed_count: if(blocked?, do: 2, else: 0), verdict: if(blocked?, do: "BLOCKED", else: "CLEAR") }) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) analysis = :sys.get_state(lv.pid).socket.assigns.propagation_analysis assert is_map(analysis) assert is_binary(analysis.summary) assert is_list(analysis.details) assert Enum.all?(analysis.details, &is_binary/1) end end property "sort-observations handler keeps the assign as a list of stable length", %{conn: conn} do contact = create_contact(%{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 }) for i <- 1..3 do Repo.insert!( SurfaceObservation.changeset(%SurfaceObservation{}, %{ station_id: station.id, observed_at: DateTime.add(contact.qso_timestamp, i * 60, :second), temp_f: 60.0 + i, dewpoint_f: 45.0 + i, relative_humidity: 55.0, sea_level_pressure_mb: 1014.0 }) ) end gen_field = StreamData.one_of([ StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)), StreamData.string(:alphanumeric, min_length: 1, max_length: 8) ]) check all(fields <- StreamData.list_of(gen_field, min_length: 1, max_length: 5), max_runs: 10) do {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) baseline = length(:sys.get_state(lv.pid).socket.assigns.surface_observations) for f <- fields do render_click(lv, "sort", %{"field" => f, "table" => "obs"}) obs = :sys.get_state(lv.pid).socket.assigns.surface_observations assert is_list(obs) assert length(obs) == baseline, "sorting by #{inspect(f)} changed row count" end end end end describe "Show ordering and proximity helpers" do use ExUnitProperties alias Microwaveprop.Geo alias Microwaveprop.Weather alias Microwaveprop.Weather.Sounding alias Microwaveprop.Weather.SurfaceObservation defp seed_distinct_obs(contact) do Enum.map( [ {"KAAA", 32.90, -97.00, 60.0, 40.0, 50.0, 1010.0, 0}, {"KBBB", 32.91, -97.01, 62.0, 42.0, 52.0, 1011.0, 60}, {"KCCC", 32.92, -97.02, 64.0, 44.0, 54.0, 1012.0, 120}, {"KDDD", 32.93, -97.03, 66.0, 46.0, 56.0, 1013.0, 180} ], fn {code, lat, lon, temp, dewp, rh, pres, offset} -> {:ok, station} = Weather.find_or_create_station(%{ station_code: code, station_type: "asos", name: "#{code} Airport", lat: lat, lon: lon }) Repo.insert!( SurfaceObservation.changeset(%SurfaceObservation{}, %{ station_id: station.id, observed_at: DateTime.add(contact.qso_timestamp, offset, :second), temp_f: temp, dewpoint_f: dewp, relative_humidity: rh, sea_level_pressure_mb: pres }) ) code end ) end test "sort_observations orders by station_name ascending and descending", %{conn: conn} do contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) codes = seed_distinct_obs(contact) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) _ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "obs"}) _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"}) asc_codes = lv.pid |> :sys.get_state() |> Map.fetch!(:socket) |> Map.fetch!(:assigns) |> Map.fetch!(:surface_observations) |> Enum.map(& &1.station.station_code) assert asc_codes == Enum.sort(codes) _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "obs"}) desc_codes = lv.pid |> :sys.get_state() |> Map.fetch!(:socket) |> Map.fetch!(:assigns) |> Map.fetch!(:surface_observations) |> Enum.map(& &1.station.station_code) assert desc_codes == Enum.sort(codes, :desc) end test "sort_observations orders by observed_at, temp_f, dewpoint_f, relative_humidity, and pressure", %{conn: conn} do contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) _ = seed_distinct_obs(contact) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) check_field = fn field, reader -> _ = render_click(lv, "sort", %{"field" => field, "table" => "obs"}) obs = lv.pid |> :sys.get_state() |> Map.fetch!(:socket) |> Map.fetch!(:assigns) |> Map.fetch!(:surface_observations) values = Enum.map(obs, reader) assert values == Enum.sort(values), "obs not ascending on #{field}: #{inspect(values)}" end check_field.("observed_at", & &1.observed_at) check_field.("temp_f", & &1.temp_f) check_field.("dewpoint_f", & &1.dewpoint_f) check_field.("relative_humidity", & &1.relative_humidity) check_field.("sea_level_pressure_mb", & &1.sea_level_pressure_mb) end defp seed_distinct_soundings(contact) do Enum.map( [ {"KAAAS", 15.0, 310.0, 20.0, -2.0, 0}, {"KBBBS", 18.0, 315.0, 25.0, -1.0, 3600}, {"KCCCS", 21.0, 320.0, 30.0, 0.0, 7200} ], fn {code, temp, refr, k, li, offset} -> {:ok, station} = Weather.find_or_create_station(%{ station_code: code, station_type: "sounding", name: "#{code} Sounding", lat: 32.9 + :rand.uniform() * 0.001, lon: -97.0 + :rand.uniform() * 0.001 }) Repo.insert!( Sounding.changeset(%Sounding{}, %{ station_id: station.id, observed_at: DateTime.add(contact.qso_timestamp, offset, :second), profile: [%{"pres" => 1000.0, "tmpc" => temp, "dwpc" => temp - 5.0, "hght" => 100.0}], level_count: 1, surface_temp_c: temp, surface_dewpoint_c: temp - 5.0, surface_refractivity: refr, k_index: k, lifted_index: li }) ) code end ) end test "sort_soundings orders by every known sounding field", %{conn: conn} do contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) _ = seed_distinct_soundings(contact) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) _ = render_click(lv, "sort", %{"field" => "observed_at", "table" => "soundings"}) _ = render_click(lv, "sort", %{"field" => "station_name", "table" => "soundings"}) station_names = lv.pid |> :sys.get_state() |> Map.fetch!(:socket) |> Map.fetch!(:assigns) |> Map.fetch!(:soundings) |> Enum.map(&(&1.station.name || &1.station.station_code)) assert station_names == Enum.sort(station_names) check_field = fn field, reader -> _ = render_click(lv, "sort", %{"field" => field, "table" => "soundings"}) rows = lv.pid |> :sys.get_state() |> Map.fetch!(:socket) |> Map.fetch!(:assigns) |> Map.fetch!(:soundings) values = Enum.map(rows, reader) assert values == Enum.sort(values), "soundings not ascending on #{field}: #{inspect(values)}" end check_field.("observed_at", & &1.observed_at) check_field.("surface_temp_c", & &1.surface_temp_c) check_field.("surface_refractivity", & &1.surface_refractivity) check_field.("k_index", & &1.k_index) check_field.("lifted_index", & &1.lifted_index) end test "closest_observations/4 keeps only the five nearest by midpoint distance", %{conn: conn} do contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) mid_lat = (32.9 + 30.3) / 2 mid_lon = (-97.0 + -97.7) / 2 near_to_far = [ {"KMID0", mid_lat, mid_lon}, {"KMID1", mid_lat + 0.05, mid_lon + 0.05}, {"KMID2", mid_lat - 0.05, mid_lon - 0.05}, {"KMID3", mid_lat + 0.10, mid_lon + 0.10}, {"KMID4", mid_lat - 0.10, mid_lon - 0.10}, {"KMID5", mid_lat + 0.20, mid_lon + 0.20}, {"KMID6", mid_lat - 0.20, mid_lon - 0.20} ] _ = Enum.map(near_to_far, fn {code, lat, lon} -> {:ok, station} = Weather.find_or_create_station(%{ station_code: code, station_type: "asos", name: "#{code}", lat: lat, lon: lon }) 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 }) ) end) {:ok, lv, _html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) obs = lv.pid |> :sys.get_state() |> Map.fetch!(:socket) |> Map.fetch!(:assigns) |> Map.fetch!(:surface_observations) assert length(obs) <= 5 distances = Enum.map(obs, fn o -> abs(o.station.lat - mid_lat) + abs(o.station.lon - mid_lon) end) assert distances == Enum.sort(distances) end test "contact with pos1 but nil pos2 renders without blowing up (half_dist = 0 path)", %{conn: conn} do contact = create_contact() {:ok, contact} = contact |> Ecto.Changeset.change(pos2: nil, grid2: nil, distance_km: nil) |> Repo.update() assert is_nil(contact.pos2) {:ok, lv, html} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) assert html =~ "W5XD" assert Process.alive?(lv.pid) end property "sort_observations/3 preserves list length + element set for any field key", %{conn: conn} do contact = create_contact(%{qso_timestamp: ~U[2026-03-28 18:00:00Z]}) codes = seed_distinct_obs(contact) fields_gen = StreamData.one_of([ StreamData.member_of(~w(station_name observed_at temp_f dewpoint_f relative_humidity sea_level_pressure_mb)), StreamData.string(:alphanumeric, min_length: 1, max_length: 6) ]) check all(fields <- StreamData.list_of(fields_gen, min_length: 1, max_length: 5), max_runs: 10) do {:ok, lv, _} = live(conn, ~p"/contacts/#{contact.id}") _ = render_async(lv, 2_000) for f <- fields do _ = render_click(lv, "sort", %{"field" => f, "table" => "obs"}) obs = lv.pid |> :sys.get_state() |> Map.fetch!(:socket) |> Map.fetch!(:assigns) |> Map.fetch!(:surface_observations) assert length(obs) == length(codes) assert Enum.sort(Enum.map(obs, & &1.station.station_code)) == Enum.sort(codes) end end end property "haversine_km/4 is symmetric: d(a,b,c,d) == d(c,d,a,b)" do check all( lat1 <- StreamData.float(min: -89.0, max: 89.0), lon1 <- StreamData.float(min: -179.0, max: 179.0), lat2 <- StreamData.float(min: -89.0, max: 89.0), lon2 <- StreamData.float(min: -179.0, max: 179.0) ) do ab = Geo.haversine_km(lat1, lon1, lat2, lon2) ba = Geo.haversine_km(lat2, lon2, lat1, lon1) assert_in_delta ab, ba, 1.0e-6 end end end end