diff --git a/test/microwaveprop/pskr/feature_bin_test.exs b/test/microwaveprop/pskr/feature_bin_test.exs new file mode 100644 index 00000000..42a8b72c --- /dev/null +++ b/test/microwaveprop/pskr/feature_bin_test.exs @@ -0,0 +1,61 @@ +defmodule Microwaveprop.Pskr.FeatureBinTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Pskr.FeatureBin + + describe "changeset/2" do + test "valid attrs produce a valid changeset" do + attrs = %{ + run_id: Ecto.UUID.generate(), + band: "10000", + feature: "pwat_mm", + bin_label: "25-40", + sample_count: 100 + } + + changeset = FeatureBin.changeset(%FeatureBin{}, attrs) + assert changeset.valid? + end + + test "missing required fields makes changeset invalid" do + changeset = FeatureBin.changeset(%FeatureBin{}, %{}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).run_id + assert "can't be blank" in errors_on(changeset).band + assert "can't be blank" in errors_on(changeset).feature + assert "can't be blank" in errors_on(changeset).bin_label + end + + test "spot count fields are not required" do + attrs = %{ + run_id: Ecto.UUID.generate(), + band: "10000", + feature: "pwat_mm", + bin_label: "<15", + sample_count: 50 + } + + changeset = FeatureBin.changeset(%FeatureBin{}, attrs) + assert changeset.valid? + end + + test "accepts optional float fields" do + attrs = %{ + run_id: Ecto.UUID.generate(), + band: "144", + feature: "hpbl_m", + bin_label: "250-500", + sample_count: 30, + bin_min: 250.0, + bin_max: 500.0, + spot_count_avg: 3.5, + spot_count_p50: 3.0, + spot_count_p90: 8.0 + } + + changeset = FeatureBin.changeset(%FeatureBin{}, attrs) + assert changeset.valid? + assert get_change(changeset, :bin_min) == 250.0 + end + end +end diff --git a/test/microwaveprop/pskr/mqtt_test.exs b/test/microwaveprop/pskr/mqtt_test.exs index 4181d939..6c4e97c2 100644 --- a/test/microwaveprop/pskr/mqtt_test.exs +++ b/test/microwaveprop/pskr/mqtt_test.exs @@ -99,5 +99,41 @@ defmodule Microwaveprop.Pskr.MqttTest do tail = <<0xC0, 0x00>> assert {:ok, :pingresp, ^tail} = Mqtt.parse(first <> tail) end + + test "empty buffer returns :incomplete" do + assert {:incomplete, <<>>} = Mqtt.parse(<<>>) + end + + test "rejects PUBLISH with QoS > 0" do + # PUBLISH with QoS 1 (type=3, flags=2 → 0x32) + assert {:error, {:unsupported_qos, 1}} = Mqtt.parse(<<0x32, 0x00>>) + end + + test "returns error for unknown packet type" do + assert {:error, {:unhandled_packet_type, _}} = Mqtt.parse(<<0x80, 0x00>>) + end + end + + describe "pingreq/0" do + test "produces 0xC0 0x00" do + assert Mqtt.pingreq() == <<0xC0, 0x00>> + end + end + + describe "disconnect/0" do + test "produces 0xE0 0x00" do + assert Mqtt.disconnect() == <<0xE0, 0x00>> + end + end + + describe "decode_varint error paths" do + test "returns error on varint longer than 4 bytes" do + # 5 continuation bytes — spec says max 4 + assert {:error, :varint_too_long} = Mqtt.decode_varint(<<0x80, 0x80, 0x80, 0x80, 0x00>>) + end + + test "returns incomplete on empty buffer" do + assert :incomplete == Mqtt.decode_varint(<<>>) + end end end diff --git a/test/microwaveprop/rover/compute_test.exs b/test/microwaveprop/rover/compute_test.exs index 52123245..9042347f 100644 --- a/test/microwaveprop/rover/compute_test.exs +++ b/test/microwaveprop/rover/compute_test.exs @@ -195,4 +195,329 @@ defmodule Microwaveprop.Rover.ComputeTest do assert result.cells == [] assert result.top_candidates == [] end + + test "road proximity penalty reduces score" do + home = %{lat: 33.0, lon: -96.0, elev_m: 200} + stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}] + grid = build_grid(home.lat, home.lon) + + scores_at = fn _, _, _ -> grid end + elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end + clearance_lookup = fn _, _ -> %{} end + prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end + + cell_with_penalty = {33.1, -96.0} + + road_lookup = fn cells, _bbox -> + {:ok, + Map.new(cells, fn c -> + dist = if {c.lat, c.lon} == cell_with_penalty, do: 10.0, else: 0.0 + {{c.lat, c.lon}, dist} + end)} + end + + Application.put_env(:microwaveprop, :rover_road_proximity_enabled, true) + + try do + result = + Compute.run( + %{ + home: home, + stations: stations, + band_mhz: 10_000, + valid_time: ~U[2026-04-25 12:00:00Z], + mode: :ssb, + max_distance_km: 65.0, + min_elev_gain: 0 + }, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup, + prominence_lookup: prominence_lookup, + road_lookup: road_lookup + ) + + cell_scores = Map.new(result.cells, fn c -> {{c.lat, c.lon}, c.score} end) + penalized_score = cell_scores[cell_with_penalty] + other_scores = Map.drop(cell_scores, [cell_with_penalty]) |> Map.values() + assert penalized_score != nil + assert Enum.all?(other_scores, fn s -> s > penalized_score end) + after + Application.put_env(:microwaveprop, :rover_road_proximity_enabled, true) + end + end + + test "returns warnings when grid is empty" do + home = %{lat: 33.0, lon: -96.0, elev_m: 200} + stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}] + + scores_at = fn _, _, _ -> [] end + elev_lookup = fn _ -> %{} end + clearance_lookup = fn _, _ -> %{} end + prominence_lookup = fn _ -> %{} end + road_lookup = fn _, _ -> {:error, :stubbed} end + + result = + Compute.run( + %{ + home: home, + stations: stations, + band_mhz: 10_000, + valid_time: ~U[2026-04-25 12:00:00Z], + mode: :ssb, + max_distance_km: 65.0, + min_elev_gain: 0 + }, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup, + prominence_lookup: prominence_lookup, + road_lookup: road_lookup + ) + + assert result.cells == [] + assert result.top_candidates == [] + assert Enum.any?(result.warnings, &String.contains?(&1, "No HRRR")) + end + + test "returns all-nil elevation warning" do + home = %{lat: 33.0, lon: -96.0, elev_m: 200} + stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}] + grid = build_grid(home.lat, home.lon) + + scores_at = fn _, _, _ -> grid end + elev_lookup = fn points -> Map.new(points, fn p -> {p, nil} end) end + clearance_lookup = fn _, _ -> %{} end + prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end + road_lookup = fn _, _ -> {:error, :stubbed} end + + result = + Compute.run( + %{ + home: home, + stations: stations, + band_mhz: 10_000, + valid_time: ~U[2026-04-25 12:00:00Z], + mode: :ssb, + max_distance_km: 65.0, + min_elev_gain: 0 + }, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup, + prominence_lookup: prominence_lookup, + road_lookup: road_lookup + ) + + assert Enum.any?(result.warnings, &String.contains?(&1, "Elevation tiles")) + end + + test "canopy clutter penalty reduces score" do + home = %{lat: 33.0, lon: -96.0, elev_m: 200} + stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}] + grid = build_grid(home.lat, home.lon) + + tall_cell = {Float.round(home.lat + 0.1, 4), Float.round(home.lon, 4)} + ref_cell = {Float.round(home.lat - 0.1, 4), Float.round(home.lon, 4)} + + scores_at = fn _, _, _ -> grid end + elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end + clearance_lookup = fn _, _ -> %{} end + prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end + road_lookup = fn _, _ -> {:error, :stubbed} end + + canopy_clutter_lookup = fn cells -> + Map.new(cells, fn c -> + height = if {c.lat, c.lon} == tall_cell, do: 20.0, else: 0.0 + {{c.lat, c.lon}, height} + end) + end + + result = + Compute.run( + %{ + home: home, + stations: stations, + band_mhz: 10_000, + valid_time: ~U[2026-04-25 12:00:00Z], + mode: :ssb, + max_distance_km: 65.0, + min_elev_gain: 0 + }, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup, + prominence_lookup: prominence_lookup, + road_lookup: road_lookup, + canopy_clutter_lookup: canopy_clutter_lookup + ) + + cells_by_pos = Map.new(result.cells, fn c -> {{c.lat, c.lon}, c.score} end) + tall_score = Map.fetch!(cells_by_pos, tall_cell) + ref_score = Map.fetch!(cells_by_pos, ref_cell) + assert tall_score < ref_score + end + + test "positive prominence gives a score boost" do + home = %{lat: 33.0, lon: -96.0, elev_m: 200} + stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}] + grid = build_grid(home.lat, home.lon) + + high_cell = {Float.round(home.lat + 0.1, 4), Float.round(home.lon, 4)} + low_cell = {Float.round(home.lat - 0.1, 4), Float.round(home.lon, 4)} + + scores_at = fn _, _, _ -> grid end + elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end + clearance_lookup = fn _, _ -> %{} end + road_lookup = fn _, _ -> {:error, :stubbed} end + + prominence_lookup = fn cells -> + Map.new(cells, fn c -> + value = if {c.lat, c.lon} == high_cell, do: 90, else: 0 + {{c.lat, c.lon}, value} + end) + end + + result = + Compute.run( + %{ + home: home, + stations: stations, + band_mhz: 10_000, + valid_time: ~U[2026-04-25 12:00:00Z], + mode: :ssb, + max_distance_km: 65.0, + min_elev_gain: 0 + }, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup, + prominence_lookup: prominence_lookup, + road_lookup: road_lookup + ) + + cells_by_pos = Map.new(result.cells, fn c -> {{c.lat, c.lon}, c.score} end) + high_score = Map.fetch!(cells_by_pos, high_cell) + low_score = Map.fetch!(cells_by_pos, low_cell) + assert high_score > low_score + end + + test "terrain clearance gives score boost" do + home = %{lat: 33.0, lon: -96.0, elev_m: 200} + stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}] + grid = build_grid(home.lat, home.lon) + + scores_at = fn _, _, _ -> grid end + elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end + prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end + road_lookup = fn _, _ -> {:error, :stubbed} end + + # Cell near home gets positive clearance to station + near_cell = {Float.round(home.lat + 0.1, 4), Float.round(home.lon, 4)} + far_cell = {Float.round(home.lat - 0.1, 4), Float.round(home.lon, 4)} + + clearance_lookup = fn cells, stations -> + Map.new(cells, fn c -> + s = hd(stations) + key = {{c.lat, c.lon}, {s.lat, s.lon}} + value = if {c.lat, c.lon} == near_cell, do: 150.0, else: 0.0 + {key, value} + end) + end + + result = + Compute.run( + %{ + home: home, + stations: stations, + band_mhz: 10_000, + valid_time: ~U[2026-04-25 12:00:00Z], + mode: :ssb, + max_distance_km: 65.0, + min_elev_gain: 0 + }, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup, + prominence_lookup: prominence_lookup, + road_lookup: road_lookup + ) + + cells_by_pos = Map.new(result.cells, fn c -> {{c.lat, c.lon}, c.score} end) + near_score = Map.fetch!(cells_by_pos, near_cell) + far_score = Map.fetch!(cells_by_pos, far_cell) + assert near_score > far_score + end + + test "road_enabled? false skips road proximity check" do + home = %{lat: 33.0, lon: -96.0, elev_m: 200} + stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}] + grid = build_grid(home.lat, home.lon) + + scores_at = fn _, _, _ -> grid end + elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end + clearance_lookup = fn _, _ -> %{} end + prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end + road_lookup = fn _, _ -> {:error, :not_called} end + + Application.put_env(:microwaveprop, :rover_road_proximity_enabled, false) + + try do + result = + Compute.run( + %{ + home: home, + stations: stations, + band_mhz: 10_000, + valid_time: ~U[2026-04-25 12:00:00Z], + mode: :ssb, + max_distance_km: 65.0, + min_elev_gain: 0 + }, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup, + prominence_lookup: prominence_lookup, + road_lookup: road_lookup + ) + + assert result.cells != [] + assert result.top_candidates != [] + after + Application.put_env(:microwaveprop, :rover_road_proximity_enabled, true) + end + end + + test "good_locations snaps cells to exact locations" do + home = %{lat: 33.0, lon: -96.0, elev_m: 200} + stations = [%{callsign: "W5LUA", lat: 33.10, lon: -96.625, selected: true}] + grid = build_grid(home.lat, home.lon) + + scores_at = fn _, _, _ -> grid end + elev_lookup = fn points -> Map.new(points, fn p -> {p, 250} end) end + clearance_lookup = fn _, _ -> %{} end + prominence_lookup = fn cells -> Map.new(cells, fn c -> {{c.lat, c.lon}, 0} end) end + road_lookup = fn _, _ -> {:error, :stubbed} end + + result = + Compute.run( + %{ + home: home, + stations: stations, + band_mhz: 10_000, + valid_time: ~U[2026-04-25 12:00:00Z], + mode: :ssb, + max_distance_km: 65.0, + min_elev_gain: 0, + good_locations: [%{lat: 33.05, lon: -96.05}] + }, + scores_at: scores_at, + elev_lookup: elev_lookup, + clearance_lookup: clearance_lookup, + prominence_lookup: prominence_lookup, + road_lookup: road_lookup + ) + + assert result.cells != [] + end end diff --git a/test/microwaveprop/rover/elevation_test.exs b/test/microwaveprop/rover/elevation_test.exs index 6f0ac200..202fe8f5 100644 --- a/test/microwaveprop/rover/elevation_test.exs +++ b/test/microwaveprop/rover/elevation_test.exs @@ -37,5 +37,22 @@ defmodule Microwaveprop.Rover.ElevationTest do test "empty list returns empty map" do assert Elevation.lookup_many([]) == %{} end + + test "duplicate points are deduplicated before lookup" do + points = [{33.0, -96.0}, {33.0, -96.0}, {33.1, -97.0}] + result = Elevation.lookup_many(points) + + assert map_size(result) == 2 + end + + test "points on different tiles each get an entry" do + # Deliberately far apart to span different SRTM tiles + points = [{45.0, -120.0}, {30.0, -90.0}] + result = Elevation.lookup_many(points) + + assert map_size(result) == 2 + assert result[{45.0, -120.0}] == nil + assert result[{30.0, -90.0}] == nil + end end end diff --git a/test/microwaveprop/rover/link_margin_test.exs b/test/microwaveprop/rover/link_margin_test.exs index cbd8e401..773f2498 100644 --- a/test/microwaveprop/rover/link_margin_test.exs +++ b/test/microwaveprop/rover/link_margin_test.exs @@ -19,6 +19,14 @@ defmodule Microwaveprop.Rover.LinkMarginTest do test "nil maps to nil" do assert LinkMargin.score_to_db(nil) == nil end + + test "negative score maps below -25 dB" do + assert LinkMargin.score_to_db(-10) == -30.0 + end + + test "score above 100 maps above 25 dB" do + assert LinkMargin.score_to_db(120) == 35.0 + end end describe "link_margin_from_score/2" do @@ -33,5 +41,22 @@ defmodule Microwaveprop.Rover.LinkMarginTest do test "nil score returns nil" do assert LinkMargin.link_margin_from_score(nil, :ssb) == nil end + + test "score 0 in :cw mode = -7.0 dB" do + assert LinkMargin.link_margin_from_score(0, :cw) == -7.0 + end + + test "score 100 in :cw mode = 43.0 dB" do + assert LinkMargin.link_margin_from_score(100, :cw) == 43.0 + end + + test "score 30 in :ssb mode gives negative margin" do + margin = LinkMargin.link_margin_from_score(30, :ssb) + assert margin < 0 + end + + test "score 50 in :q65_60a mode = 24.0 dB" do + assert LinkMargin.link_margin_from_score(50, :q65_60a) == 24.0 + end end end diff --git a/test/microwaveprop/rover/location_test.exs b/test/microwaveprop/rover/location_test.exs index a50d6b1c..d1e908b2 100644 --- a/test/microwaveprop/rover/location_test.exs +++ b/test/microwaveprop/rover/location_test.exs @@ -56,6 +56,45 @@ defmodule Microwaveprop.RoverLocationsTest do end end + describe "Location.changeset/2" do + test "rejects lon out of range" do + changeset = Location.changeset(%Location{}, %{lat: 0.0, lon: 200.0, status: :good}) + refute changeset.valid? + assert "must be less than or equal to 180.0" in errors_on(changeset).lon + end + + test "rejects lat out of range" do + changeset = Location.changeset(%Location{}, %{lat: -91.0, lon: 0.0, status: :good}) + refute changeset.valid? + assert "must be greater than or equal to -90.0" in errors_on(changeset).lat + end + + test "rejects invalid status" do + changeset = Location.changeset(%Location{}, %{lat: 0.0, lon: 0.0, status: :unknown}) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).status + end + + test "accepts valid lat/lon at the extreme boundaries" do + changeset = + Location.changeset(%Location{}, %{lat: 90.0, lon: 180.0, status: :good}) + + assert changeset.valid? + end + + test "notes exceeding max length are rejected" do + changeset = + Location.changeset(%Location{}, %{lat: 0.0, lon: 0.0, status: :good, notes: String.duplicate("x", 4001)}) + + refute changeset.valid? + assert "should be at most 4000 character(s)" in errors_on(changeset).notes + end + + test "statuses/0 returns available status atoms" do + assert Location.statuses() == [:good, :bad] + end + end + describe "update_location/3 and delete_location/2" do test "creator can update their location" do user = user_fixture() diff --git a/test/microwaveprop/rover/prominence_test.exs b/test/microwaveprop/rover/prominence_test.exs new file mode 100644 index 00000000..99b8ce6f --- /dev/null +++ b/test/microwaveprop/rover/prominence_test.exs @@ -0,0 +1,63 @@ +defmodule Microwaveprop.Rover.ProminenceTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Rover.Prominence + + describe "prominence_map/2" do + test "returns map keyed by {lat, lon}" do + cells = [%{lat: 33.0, lon: -97.0}, %{lat: 33.1, lon: -97.1}] + elev_lookup = fn points -> + Map.new(points, fn p -> {p, 200} end) + end + + result = Prominence.prominence_map(cells, elev_lookup: elev_lookup) + + assert is_map(result) + assert Map.has_key?(result, {33.0, -97.0}) + assert Map.has_key?(result, {33.1, -97.1}) + end + + test "cell sitting above neighbours has positive prominence" do + elev_lookup = fn points -> + Map.new(points, fn + {33.0, -97.0} -> {{33.0, -97.0}, 300} + p -> {p, 100} + end) + end + + result = Prominence.prominence_map([%{lat: 33.0, lon: -97.0}], elev_lookup: elev_lookup) + assert result[{33.0, -97.0}] > 0 + end + + test "cell at same elevation as neighbours has zero prominence" do + elev_lookup = fn points -> + Map.new(points, fn p -> {p, 150} end) + end + + result = Prominence.prominence_map([%{lat: 33.0, lon: -97.0}], elev_lookup: elev_lookup) + assert result[{33.0, -97.0}] == 0 + end + + test "neighbours with nil elevations are excluded from ring average" do + elev_lookup = fn points -> + Map.new(points, fn + {33.0, -97.0} -> {{33.0, -97.0}, 200} + p -> {p, nil} + end) + end + + result = Prominence.prominence_map([%{lat: 33.0, lon: -97.0}], elev_lookup: elev_lookup) + assert result[{33.0, -97.0}] == nil + end + + test "single cell returns nil prominence (no neighbours to compare)" do + elev_lookup = fn _points -> %{{45.0, -120.0} => 500} end + result = Prominence.prominence_map([%{lat: 45.0, lon: -120.0}], elev_lookup: elev_lookup) + assert result[{45.0, -120.0}] == nil + end + + test "empty cells list returns empty map" do + assert Prominence.prominence_map([]) == %{} + end + end +end diff --git a/test/microwaveprop/rover_planning/path_test.exs b/test/microwaveprop/rover_planning/path_test.exs new file mode 100644 index 00000000..a8b2a653 --- /dev/null +++ b/test/microwaveprop/rover_planning/path_test.exs @@ -0,0 +1,87 @@ +defmodule Microwaveprop.RoverPlanning.PathTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.RoverPlanning.Path + + describe "changeset/2" do + test "valid attrs produce a valid changeset" do + attrs = %{ + mission_id: Ecto.UUID.generate(), + rover_location_id: Ecto.UUID.generate(), + station_id: Ecto.UUID.generate(), + band_mhz: 10_000, + status: :pending + } + + changeset = Path.changeset(%Path{}, attrs) + assert changeset.valid? + end + + test "missing required fields makes changeset invalid" do + changeset = Path.changeset(%Path{}, %{}) + refute changeset.valid? + assert "can't be blank" in errors_on(changeset).mission_id + assert "can't be blank" in errors_on(changeset).rover_location_id + assert "can't be blank" in errors_on(changeset).station_id + assert "can't be blank" in errors_on(changeset).band_mhz + end + + test "rejects invalid status" do + attrs = %{ + mission_id: Ecto.UUID.generate(), + rover_location_id: Ecto.UUID.generate(), + station_id: Ecto.UUID.generate(), + band_mhz: 10_000, + status: :invalid_status + } + + changeset = Path.changeset(%Path{}, attrs) + refute changeset.valid? + assert "is invalid" in errors_on(changeset).status + end + + test "rejects zero or negative band_mhz" do + attrs = %{ + mission_id: Ecto.UUID.generate(), + rover_location_id: Ecto.UUID.generate(), + station_id: Ecto.UUID.generate(), + band_mhz: 0, + status: :pending + } + + changeset = Path.changeset(%Path{}, attrs) + refute changeset.valid? + assert "must be greater than 0" in errors_on(changeset).band_mhz + end + + test "accepts complete and failed statuses" do + for status <- [:complete, :failed] do + attrs = %{ + mission_id: Ecto.UUID.generate(), + rover_location_id: Ecto.UUID.generate(), + station_id: Ecto.UUID.generate(), + band_mhz: 10_000, + status: status + } + + changeset = Path.changeset(%Path{}, attrs) + assert changeset.valid?, "status :#{status} should be valid" + end + end + + test "accepts optional result and error fields" do + attrs = %{ + mission_id: Ecto.UUID.generate(), + rover_location_id: Ecto.UUID.generate(), + station_id: Ecto.UUID.generate(), + band_mhz: 10_000, + status: :failed, + result: nil, + error: "timeout" + } + + changeset = Path.changeset(%Path{}, attrs) + assert changeset.valid? + end + end +end diff --git a/test/microwaveprop/valkey_test.exs b/test/microwaveprop/valkey_test.exs new file mode 100644 index 00000000..b23a74ef --- /dev/null +++ b/test/microwaveprop/valkey_test.exs @@ -0,0 +1,98 @@ +defmodule Microwaveprop.ValkeyTest do + use ExUnit.Case, async: true + + alias Microwaveprop.Valkey + + describe "child_spec/1" do + test "returns a valid child spec map" do + spec = Valkey.child_spec([]) + + assert spec.id == Valkey + assert spec.type == :worker + assert spec.restart == :permanent + end + end + + describe "configured?/0" do + test "returns false when Valkey is not configured" do + assert Valkey.configured?() == false + end + end + + describe "get/1" do + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.get("test_key") + end + end + + describe "mget/1" do + test "empty list returns {:ok, []}" do + assert Valkey.mget([]) == {:ok, []} + end + + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.mget(["key1", "key2"]) + end + end + + describe "set/3" do + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.set("key", "value", 3600) + end + end + + describe "mset_with_ttl/2" do + test "empty list returns :ok" do + assert Valkey.mset_with_ttl([], 3600) == :ok + end + + test "returns error when not configured" do + pairs = [{"key1", "val1"}, {"key2", "val2"}] + assert {:error, :not_configured} = Valkey.mset_with_ttl(pairs, 3600) + end + end + + describe "zadd/3" do + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.zadd("zset", 1.0, "member") + end + end + + describe "zrevrange/3" do + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.zrevrange("zset", 0, 10) + end + end + + describe "zrangebyscore/3" do + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.zrangebyscore("zset", "0", "100") + end + end + + describe "zrem/2" do + test "empty list returns :ok" do + assert Valkey.zrem("zset", []) == :ok + end + + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.zrem("zset", ["member1"]) + end + end + + describe "del/1" do + test "empty list returns :ok" do + assert Valkey.del([]) == :ok + end + + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.del(["key1"]) + end + end + + describe "scan_match/1" do + test "returns error when not configured" do + assert {:error, :not_configured} = Valkey.scan_match("pattern:*") + end + end +end diff --git a/test/microwaveprop_web/controllers/scores_controller_test.exs b/test/microwaveprop_web/controllers/scores_controller_test.exs index d1beff93..c28acb27 100644 --- a/test/microwaveprop_web/controllers/scores_controller_test.exs +++ b/test/microwaveprop_web/controllers/scores_controller_test.exs @@ -75,5 +75,30 @@ defmodule MicrowavepropWeb.ScoresControllerTest do body = response(conn, 200) assert <<"PSCR", 1::8, _reserved::24, 0::little-32>> = body end + + test "returns 400 for bad ?time format", %{conn: conn} do + conn = get(conn, ~p"/scores/cells?band=10000&time=not-a-time&south=0&north=1&west=-1&east=0") + assert json_response(conn, 400) == %{"error" => "invalid params"} + end + + test "returns 400 for bad float values in bounds", %{conn: conn} do + conn = get(conn, ~p"/scores/cells?band=10000&south=abc&north=1&west=-1&east=0") + assert json_response(conn, 400) == %{"error" => "invalid params"} + end + + test "returns 400 when band param is not an integer", %{conn: conn} do + conn = get(conn, ~p"/scores/cells?band=abc&south=0&north=1&west=-1&east=0") + assert json_response(conn, 400) == %{"error" => "invalid params"} + end + + test "returns 400 when band param is missing", %{conn: conn} do + conn = get(conn, ~p"/scores/cells?south=0&north=1&west=-1&east=0") + assert json_response(conn, 400) == %{"error" => "invalid params"} + end + + test "returns 400 when time param is an empty string", %{conn: conn} do + conn = get(conn, ~p"/scores/cells?band=10000&time=&south=0&north=1&west=-1&east=0") + assert json_response(conn, 400) == %{"error" => "invalid params"} + end end end diff --git a/test/microwaveprop_web/live/skewt_live_test.exs b/test/microwaveprop_web/live/skewt_live_test.exs index 229c1d3c..01a84b80 100644 --- a/test/microwaveprop_web/live/skewt_live_test.exs +++ b/test/microwaveprop_web/live/skewt_live_test.exs @@ -13,22 +13,17 @@ defmodule MicrowavepropWeb.SkewtLiveTest do end test "initial render with a URL query shows the page chrome + a loading indicator", %{conn: conn} do - # The URL parameter triggers an async resolve. The page must - # render the chrome (header, search bar) and a loading state - # synchronously — *before* the geocoder / HRRR fetch returns. {:ok, _view, html} = live(conn, ~p"/skewt?q=EM12kp") assert html =~ "Skew-T-Log-P" assert html =~ ~s|placeholder="EM12kp| assert html =~ "Resolving location" - # Location label has not arrived yet — that's the whole point. refute html =~ "EM12KP" end test "after the async completes the resolved location label appears", %{conn: conn} do {:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp") - # Wait for the :load_skewt async to finish, then re-render. html = render_async(view) assert html =~ "EM12KP" @@ -44,5 +39,48 @@ defmodule MicrowavepropWeb.SkewtLiveTest do assert render_async(view) =~ "EM12" end + + test "shows info alert when no HRRR profiles are stored", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp") + + html = render_async(view) + + assert html =~ "No HRRR profiles are currently stored" + assert has_element?(view, ".alert.alert-info") + end + + test "time selector is not shown when there are no valid times", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp") + + render_async(view) + + refute has_element?(view, "button[phx-click=select_time]") + end + + test "loading indicator visible while async is in flight", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp") + + assert has_element?(view, ".loading") + assert render(view) =~ "Resolving location" + end + + test "on successful async the loading state is cleared and location shown", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp") + + html = render_async(view) + + refute html =~ "Resolving location" + assert html =~ "EM12KP" + assert html =~ "32.646" + end + + test "coordinates are formatted with 3 decimal places", %{conn: conn} do + {:ok, view, _html} = live(conn, ~p"/skewt?q=EM12kp") + + html = render_async(view) + + assert html =~ "32.646" + assert html =~ "-97.125" + end end end