test: expand coverage across rover, pskr, valkey, skewt, scores
- Rover.Compute: road/prominence/clearance/clutter/canopy tests (90%) - Rover.Prominence: full unit test suite with mock elev_lookup (100%) - Rover.Elevation: dedup + multi-tile tests (44%) - Rover.LinkMargin: edge cases, negative scores, all modes (57%) - Rover.Location: changeset validation + statuses/0 (100%) - RoverPlanning.Path: changeset validation + statuses/0 (67%) - Pskr.FeatureBin: changeset validation (100%) - Pskr.Mqtt: QoS reject, unknown type, varint overflow, ping/disconnect (97%) - Valkey: not-configured error paths, empty guards (55%) - ScoresController: bad params, time format, missing band tests (81%) - SkewtLive: info/no-profiles state, loading states (29%)
This commit is contained in:
parent
a8bd29f78c
commit
9ef10f5aae
11 changed files with 819 additions and 5 deletions
61
test/microwaveprop/pskr/feature_bin_test.exs
Normal file
61
test/microwaveprop/pskr/feature_bin_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
63
test/microwaveprop/rover/prominence_test.exs
Normal file
63
test/microwaveprop/rover/prominence_test.exs
Normal file
|
|
@ -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
|
||||
87
test/microwaveprop/rover_planning/path_test.exs
Normal file
87
test/microwaveprop/rover_planning/path_test.exs
Normal file
|
|
@ -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
|
||||
98
test/microwaveprop/valkey_test.exs
Normal file
98
test/microwaveprop/valkey_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue