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