defmodule MicrowavepropWeb.ContactLive.ShowHydrationTest 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 fully-hydrated render paths" do alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Radio.ContactCommonVolumeRadar alias Microwaveprop.Terrain.TerrainProfile alias Microwaveprop.Weather alias Microwaveprop.Weather.HrrrProfile alias Microwaveprop.Weather.IemreObservation alias Microwaveprop.Weather.Sounding alias Microwaveprop.Weather.SurfaceObservation 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 import Microwaveprop.AccountsFixtures 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 =~ ">\n Edit\n " or html =~ "Edit" refute html =~ "Suggest Edit" end end describe "Show render-branch coverage" do use ExUnitProperties alias Microwaveprop.Propagation.ScoreCache alias Microwaveprop.Radio.ContactCommonVolumeRadar alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Terrain.TerrainProfile alias Microwaveprop.Weather alias Microwaveprop.Weather.HrrrProfile alias Microwaveprop.Weather.IemreObservation alias Microwaveprop.Weather.Sounding alias Microwaveprop.Weather.SurfaceObservation 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 html =~ "EXCELLENT" or html =~ "GOOD" or html =~ "MARGINAL" or html =~ "POOR" or html =~ "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 import Microwaveprop.AccountsFixtures 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 =~ "Fetching surface observations" or 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