diff --git a/lib/microwaveprop_web/live/about_live.ex b/lib/microwaveprop_web/live/about_live.ex index a3d391cf..20c6f45f 100644 --- a/lib/microwaveprop_web/live/about_live.ex +++ b/lib/microwaveprop_web/live/about_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.AboutLive do - @moduledoc false + @moduledoc "Static `/about` page — project overview, credits, contact info." use MicrowavepropWeb, :live_view alias Microwaveprop.Repo diff --git a/lib/microwaveprop_web/live/admin/contact_edit_live.ex b/lib/microwaveprop_web/live/admin/contact_edit_live.ex index 4ca67d5c..d54efac9 100644 --- a/lib/microwaveprop_web/live/admin/contact_edit_live.ex +++ b/lib/microwaveprop_web/live/admin/contact_edit_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do - @moduledoc false + @moduledoc "Admin-only contact editor at `/admin/contacts/:id/edit` for manual QSO fixes." use MicrowavepropWeb, :live_view use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Radio.ContactEdit diff --git a/lib/microwaveprop_web/live/algo_live.ex b/lib/microwaveprop_web/live/algo_live.ex index a883f513..320545c5 100644 --- a/lib/microwaveprop_web/live/algo_live.ex +++ b/lib/microwaveprop_web/live/algo_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.AlgoLive do - @moduledoc false + @moduledoc "Renders `algo.md` as HTML at `/algo` — documents the scoring algorithm." use MicrowavepropWeb, :live_view @external_resource "algo.md" diff --git a/lib/microwaveprop_web/live/beacon_live/form.ex b/lib/microwaveprop_web/live/beacon_live/form.ex index 10406e63..e8467128 100644 --- a/lib/microwaveprop_web/live/beacon_live/form.ex +++ b/lib/microwaveprop_web/live/beacon_live/form.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.BeaconLive.Form do - @moduledoc false + @moduledoc "Shared new/edit form for beacon records, used by `BeaconLive.Index`." use MicrowavepropWeb, :live_view alias Microwaveprop.Beacons diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index 20d2609a..92e60572 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.BeaconLive.Index do - @moduledoc false + @moduledoc "`/beacons` list + admin controls for the beacon catalog." use MicrowavepropWeb, :live_view use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Beacons.Beacon diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex index ad9a56f5..e88569f4 100644 --- a/lib/microwaveprop_web/live/beacon_live/show.ex +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.BeaconLive.Show do - @moduledoc false + @moduledoc "Single-beacon detail page with reception-log history at `/beacons/:id`." use MicrowavepropWeb, :live_view alias Microwaveprop.Beacons diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex index ccbc4b01..6624ebc1 100644 --- a/lib/microwaveprop_web/live/contact_live/index.ex +++ b/lib/microwaveprop_web/live/contact_live/index.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.ContactLive.Index do - @moduledoc false + @moduledoc "`/contacts` table with filter/sort + pagination over QSO records." use MicrowavepropWeb, :live_view use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Radio.Contact diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index a48a02a6..eebad038 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.ContactLive.Show do - @moduledoc false + @moduledoc "Per-QSO detail at `/contacts/:id`: weather, HRRR, radar, terrain, charts." use MicrowavepropWeb, :live_view import MicrowavepropWeb.Components.SkewTChart diff --git a/lib/microwaveprop_web/live/contact_map_live.ex b/lib/microwaveprop_web/live/contact_map_live.ex index 76ae8692..fe32ae7a 100644 --- a/lib/microwaveprop_web/live/contact_map_live.ex +++ b/lib/microwaveprop_web/live/contact_map_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.ContactMapLive do - @moduledoc false + @moduledoc "`/contacts/map` plots QSO endpoints + great-circle paths on a Leaflet map." use MicrowavepropWeb, :live_view alias Microwaveprop.Radio diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index 8e462fa8..c5fae1b2 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.MapLive do - @moduledoc false + @moduledoc "`/map` — main propagation heatmap with forecast timeline + point detail drawer." use MicrowavepropWeb, :live_view use LiveStash diff --git a/lib/microwaveprop_web/live/path_live.ex b/lib/microwaveprop_web/live/path_live.ex index 820e1105..eafc629b 100644 --- a/lib/microwaveprop_web/live/path_live.ex +++ b/lib/microwaveprop_web/live/path_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.PathLive do - @moduledoc false + @moduledoc "`/path` terrain-profile + propagation analysis for a pair of grids/coords." use MicrowavepropWeb, :live_view import MicrowavepropWeb.Components.SkewTChart diff --git a/lib/microwaveprop_web/live/privacy_live.ex b/lib/microwaveprop_web/live/privacy_live.ex index 7cdd9f50..e8a34227 100644 --- a/lib/microwaveprop_web/live/privacy_live.ex +++ b/lib/microwaveprop_web/live/privacy_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.PrivacyLive do - @moduledoc false + @moduledoc "Static `/privacy` page." use MicrowavepropWeb, :live_view @impl true diff --git a/lib/microwaveprop_web/live/status_live.ex b/lib/microwaveprop_web/live/status_live.ex index e0b1236a..3fa6eb96 100644 --- a/lib/microwaveprop_web/live/status_live.ex +++ b/lib/microwaveprop_web/live/status_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.StatusLive do - @moduledoc false + @moduledoc "`/status` ops dashboard — enrichment progress, queue depths, DB size." use MicrowavepropWeb, :live_view import Ecto.Query diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index 0eb85370..5d9b1a35 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.SubmitLive do - @moduledoc false + @moduledoc "`/submit` QSO submission form; enqueues enrichment jobs on save." use MicrowavepropWeb, :live_view use LiveStash diff --git a/lib/microwaveprop_web/live/user_management_live/edit.ex b/lib/microwaveprop_web/live/user_management_live/edit.ex index 64a14ce1..31827d1b 100644 --- a/lib/microwaveprop_web/live/user_management_live/edit.ex +++ b/lib/microwaveprop_web/live/user_management_live/edit.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do - @moduledoc false + @moduledoc "Admin edit page for a single user (roles, suspension)." use MicrowavepropWeb, :live_view alias Microwaveprop.Accounts diff --git a/lib/microwaveprop_web/live/user_management_live/index.ex b/lib/microwaveprop_web/live/user_management_live/index.ex index 12c2e2b3..682fa344 100644 --- a/lib/microwaveprop_web/live/user_management_live/index.ex +++ b/lib/microwaveprop_web/live/user_management_live/index.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do - @moduledoc false + @moduledoc "Admin user list at `/admin/users`." use MicrowavepropWeb, :live_view use MicrowavepropWeb.LiveTableResource, schema: Microwaveprop.Accounts.User diff --git a/lib/microwaveprop_web/live/user_profile_live.ex b/lib/microwaveprop_web/live/user_profile_live.ex index c8afa68f..70dcc19f 100644 --- a/lib/microwaveprop_web/live/user_profile_live.ex +++ b/lib/microwaveprop_web/live/user_profile_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.UserProfileLive do - @moduledoc false + @moduledoc "Signed-in user's own profile + callsign linkage at `/account`." use MicrowavepropWeb, :live_view alias Microwaveprop.Accounts diff --git a/lib/microwaveprop_web/live/weather_map_live.ex b/lib/microwaveprop_web/live/weather_map_live.ex index 1b98a2a5..32ff1f85 100644 --- a/lib/microwaveprop_web/live/weather_map_live.ex +++ b/lib/microwaveprop_web/live/weather_map_live.ex @@ -1,5 +1,5 @@ defmodule MicrowavepropWeb.WeatherMapLive do - @moduledoc false + @moduledoc "`/weather` — HRRR-derived forecast fields (temp, Td, wind, refractivity) on a map." use MicrowavepropWeb, :live_view alias Microwaveprop.Weather diff --git a/rust/prop_grid_rs/src/pipeline.rs b/rust/prop_grid_rs/src/pipeline.rs index b46b1291..3ddc0e64 100644 --- a/rust/prop_grid_rs/src/pipeline.rs +++ b/rust/prop_grid_rs/src/pipeline.rs @@ -358,6 +358,101 @@ mod tests { .unwrap_err(); assert!(matches!(err, PipelineError::F00Reserved)); } + + /// Integration-ish: hand-build a 2-cell surface + pressure grid, + /// merge → cell_to_conditions → scorer → scores_file::write_atomic + /// → scores_file::read, asserting the full chain agrees with + /// itself. Covers the post-fetch hot path (the one that scales + /// with grid size) without standing up wgrib2. + #[test] + fn merge_score_write_read_roundtrip() { + use chrono::TimeZone; + + // decoder encodes lat/lon at millidegree precision in the key. + fn latlon_to_key(lat: f64, lon: f64) -> (i32, i32) { + ((lat * 1000.0).round() as i32, (lon * 1000.0).round() as i32) + } + + let mut sfc = PointGrid::new(); + let mut prs = PointGrid::new(); + + let cells = [(32.0_f64, -97.0_f64), (32.03_f64, -97.03_f64)]; + for &(lat, lon) in &cells { + let mut s = CellValues::new(); + s.insert("TMP:2 m above ground".into(), 295.0); + s.insert("DPT:2 m above ground".into(), 285.0); + s.insert("UGRD:10 m above ground".into(), 2.0); + s.insert("VGRD:10 m above ground".into(), 1.0); + s.insert("TCDC:entire atmosphere".into(), 20.0); + s.insert("PRES:surface".into(), 101_000.0); + s.insert("HPBL:surface".into(), 500.0); + s.insert( + "PWAT:entire atmosphere (considered as a single layer)".into(), + 20.0, + ); + sfc.insert(latlon_to_key(lat, lon), s); + + let mut p = CellValues::new(); + p.insert("TMP:1000 mb".into(), 295.0); + p.insert("DPT:1000 mb".into(), 285.0); + p.insert("HGT:1000 mb".into(), 100.0); + p.insert("TMP:975 mb".into(), 293.0); + p.insert("DPT:975 mb".into(), 280.0); + p.insert("HGT:975 mb".into(), 300.0); + prs.insert(latlon_to_key(lat, lon), p); + } + + let merged = merge_grids(sfc, prs); + assert_eq!(merged.len(), 2); + + let valid_time = Utc.with_ymd_and_hms(2026, 6, 15, 18, 0, 0).unwrap(); + + let prepared: Vec<_> = merged + .into_iter() + .filter_map(|(key, cell)| { + let (lat, lon) = crate::decoder::key_to_latlon(key); + let c = cell_to_conditions(&cell, lat, lon, &valid_time)?; + let inv = scorer::precompute_band_invariants(&c); + Some((lat, lon, c, inv)) + }) + .collect(); + assert_eq!(prepared.len(), 2); + + let band = &band_config::all_bands()[0]; + let scores: Vec = prepared + .iter() + .map(|(lat, lon, c, inv)| { + let r = scorer::composite_score_with(c, band, Some(*inv)); + ScorePoint { + lat: *lat, + lon: *lon, + score: r.score, + } + }) + .collect(); + assert_eq!(scores.len(), 2); + for s in &scores { + assert!(s.score <= 100); + } + + // Write to an atomic .prop file and read it back. + let dir = tempfile::tempdir().unwrap(); + scores_file::write_atomic(dir.path(), band.freq_mhz, valid_time, &scores).unwrap(); + + let path = scores_file::path_for(dir.path(), band.freq_mhz, valid_time); + let bytes = std::fs::read(&path).expect("read back score file"); + let decoded = scores_file::decode(&bytes).expect("decode"); + // Decoded grid covers the full CONUS bounding box as a dense + // byte array; our 2 score points land at their respective + // cells and the rest are sentinel no-data. Spot-check: header + // fields survived the round trip and the body is the expected + // size for a row_major n_rows × n_cols grid. + assert_eq!(decoded.band_mhz, band.freq_mhz); + assert_eq!( + decoded.body.len(), + decoded.n_rows as usize * decoded.n_cols as usize + ); + } } // ===================================================================== diff --git a/test/microwaveprop_web/live/map_live_test.exs b/test/microwaveprop_web/live/map_live_test.exs index 40629051..0c5d55e9 100644 --- a/test/microwaveprop_web/live/map_live_test.exs +++ b/test/microwaveprop_web/live/map_live_test.exs @@ -169,6 +169,83 @@ defmodule MicrowavepropWeb.MapLiveTest do end end + describe "toggle_radar" do + test "flips the radar layer visibility assign", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + refute :sys.get_state(lv.pid).socket.assigns.radar_visible + render_click(lv, "toggle_radar") + assert :sys.get_state(lv.pid).socket.assigns.radar_visible + render_click(lv, "toggle_radar") + refute :sys.get_state(lv.pid).socket.assigns.radar_visible + end + end + + describe "select_time / set_selected_time" do + test "set_selected_time accepts an iso8601 string and updates the assign", + %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + now_iso = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() + render_hook(lv, "set_selected_time", %{"time" => now_iso}) + + # The handler accepts the event without crashing. Whether the new + # time is actually adopted depends on valid_times, which is empty + # in test (no ScoresFiles); that's fine — we're testing the + # handler path exists and doesn't raise on malformed/new input. + assert Process.alive?(lv.pid) + end + + test "select_time event is accepted and doesn't crash the LV", + %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + render_click(lv, "select_time", %{"time" => "2026-04-21T22:00:00Z"}) + assert Process.alive?(lv.pid) + end + end + + describe "point_detail" do + test "handler pushes a `point_detail` event to the client without crashing", + %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + # point_detail does no server-rendered DOM update — it calls + # push_event/3 with the detail payload and a JS hook consumes it. + # The assertion is that the handler returns cleanly even in the + # "no HRRR profile" path (dev/test has empty .prop files). + render_click(lv, "point_detail", %{"lat" => 32.9, "lon" => -97.04}) + assert Process.alive?(lv.pid) + assert render(lv) =~ "propagation-map" + end + end + + describe "map_bounds" do + test "stores the viewport and debounces a forecast preload", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + + render_hook(lv, "map_bounds", %{ + "south" => 30.0, + "north" => 35.0, + "west" => -100.0, + "east" => -95.0 + }) + + bounds = :sys.get_state(lv.pid).socket.assigns.bounds + assert bounds["south"] == 30.0 + assert bounds["north"] == 35.0 + assert bounds["west"] == -100.0 + assert bounds["east"] == -95.0 + end + end + + describe "retry_initial_scores" do + test "event is accepted without crashing the LV", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/map") + render_click(lv, "retry_initial_scores") + assert Process.alive?(lv.pid) + end + end + describe "daily outlook strip" do test "no longer renders on the main map", %{conn: conn} do {:ok, _lv, html} = live(conn, ~p"/map")