prop/test/microwaveprop/space_weather/observation_changesets_test.exs
Graham McIntire 764643bc62
test: expand coverage and refactor to idiomatic style
Changes under lib/ (source):
- iemre_fetch_worker: replace `if transient_failure?/1` boolean predicate
  + `if/else` with a classifier that pattern-matches the error reason
  directly, returning `:transient | :permanent`, and dispatch via
  `case`. Three-clause head is clearer than the boolean predicate.
- scores_file.extract_points and nexrad_client.extract_box: force `//1`
  step on the row/col comprehensions so an image box whose centre
  lands outside the raster collapses to an empty iteration instead
  of firing Elixir 1.19's ambiguous-range warning. Adds a regression
  test for the right-edge box.

Test coverage added:
- Feature wrappers in Backtest.Features (theta_e_jump, shear_at_top,
  duct_thickness, best_duct_freq, duct_usable_for_band,
  distance_to_front / parallel_to_front stubs, all_features/0).
- NotifyListener handle_info tolerance for malformed payloads +
  unknown messages, plus the PubSub broadcast side effect.
- Viewshed.effective_reach_km across every verdict / diffraction
  tier / ducting-score tier combination.
- SnmpClient parse_af60_output unit conversions + unknown-column
  handling; parse_snmpget_output OID normalisation and junk-line
  tolerance.
- HrrrNativeClient native_level_count, native_variables,
  native_messages shape, duct_messages / duct_byte_ranges.
- Changeset coverage for GeomagneticObservation, SolarFluxObservation,
  SolarXrayObservation, Ionosphere.Observation, HrrrClimatology, and
  Metar5minObservation — lifted each from ~50% / 0% to 100%.
- Property tests: GefsClient.dewpoint_from_rh invariants (Td ≤ T,
  monotonic in RH, finite across the operating envelope) + idempotent
  nearest_run. NexradClient.pixel_to_dbz monotonicity + bounded
  output; latlon_to_pixel round-trip for every grid cell.

Also cleared several unrelated compiler/linter warnings:
- Three private test helpers (create_contact, insert_contact,
  current_hour) had `\\ %{}` / `\\ 0` defaults that no caller used.
- profiles_file_test bound `dir` in a pattern match without using it.

Suite: 2,359 tests + 146 properties pass (was 2,300 / 139); coverage
70.38% → 70.89%; credo strict clean.
2026-04-23 13:28:42 -05:00

234 lines
7.4 KiB
Elixir

defmodule Microwaveprop.SpaceWeather.ObservationChangesetsTest do
@moduledoc """
Cast/validate coverage for the three space-weather observation
schemas. These were historically at ~50 % line coverage because
only the happy path of `swpc_client` exercised `changeset/2` — the
required/optional splits and the unique constraints had no direct
assertions.
"""
use ExUnit.Case, async: true
alias Microwaveprop.Ionosphere.Observation, as: IonoObs
alias Microwaveprop.SpaceWeather.GeomagneticObservation
alias Microwaveprop.SpaceWeather.SolarFluxObservation
alias Microwaveprop.SpaceWeather.SolarXrayObservation
alias Microwaveprop.Weather.HrrrClimatology
alias Microwaveprop.Weather.Metar5minObservation
describe "GeomagneticObservation.changeset/2" do
test "valid with only valid_time" do
cs = GeomagneticObservation.changeset(%GeomagneticObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]})
assert cs.valid?
end
test "accepts kp_index and estimated_kp" do
cs =
GeomagneticObservation.changeset(%GeomagneticObservation{}, %{
valid_time: ~U[2026-04-21 00:00:00Z],
kp_index: 4,
estimated_kp: 4.33
})
assert cs.valid?
assert Ecto.Changeset.get_change(cs, :kp_index) == 4
assert Ecto.Changeset.get_change(cs, :estimated_kp) == 4.33
end
test "invalid when valid_time is missing" do
cs = GeomagneticObservation.changeset(%GeomagneticObservation{}, %{kp_index: 3})
refute cs.valid?
assert %{valid_time: ["can't be blank"]} = errors_on(cs)
end
end
describe "SolarFluxObservation.changeset/2" do
test "valid with required valid_time" do
cs = SolarFluxObservation.changeset(%SolarFluxObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]})
assert cs.valid?
end
test "accepts frequency, flux, schedule, and 90-day mean" do
cs =
SolarFluxObservation.changeset(%SolarFluxObservation{}, %{
valid_time: ~U[2026-04-21 00:00:00Z],
frequency_mhz: 2800,
flux_sfu: 142.4,
reporting_schedule: "daily",
ninety_day_mean: 150.0
})
assert cs.valid?
end
test "invalid when valid_time is missing" do
cs = SolarFluxObservation.changeset(%SolarFluxObservation{}, %{flux_sfu: 142.4})
refute cs.valid?
assert %{valid_time: ["can't be blank"]} = errors_on(cs)
end
end
describe "SolarXrayObservation.changeset/2" do
test "valid with required valid_time" do
cs = SolarXrayObservation.changeset(%SolarXrayObservation{}, %{valid_time: ~U[2026-04-21 00:00:00Z]})
assert cs.valid?
end
test "casts satellite, energy_band, flux, and electron_contaminated" do
cs =
SolarXrayObservation.changeset(%SolarXrayObservation{}, %{
valid_time: ~U[2026-04-21 00:00:00Z],
satellite: 16,
flux_wm2: 1.23e-6,
energy_band: "0.1-0.8nm",
electron_contaminated: true
})
assert cs.valid?
assert Ecto.Changeset.get_change(cs, :electron_contaminated) == true
end
test "invalid when valid_time is missing" do
cs = SolarXrayObservation.changeset(%SolarXrayObservation{}, %{flux_wm2: 1.0e-7})
refute cs.valid?
assert %{valid_time: ["can't be blank"]} = errors_on(cs)
end
end
describe "Ionosphere.Observation.changeset/2" do
test "valid with required station_code + valid_time" do
cs =
IonoObs.changeset(%IonoObs{}, %{
station_code: "BC840",
valid_time: ~U[2026-04-21 00:00:00Z]
})
assert cs.valid?
end
test "casts scaled characteristics" do
cs =
IonoObs.changeset(%IonoObs{}, %{
station_code: "BC840",
valid_time: ~U[2026-04-21 00:00:00Z],
confidence_score: 75,
fo_f2_mhz: 10.4,
fo_e_mhz: 3.2,
fo_es_mhz: 6.1,
hm_f2_km: 275.0,
mufd_mhz: 18.5
})
assert cs.valid?
assert Ecto.Changeset.get_change(cs, :fo_f2_mhz) == 10.4
assert Ecto.Changeset.get_change(cs, :confidence_score) == 75
end
test "invalid when either required field is missing" do
cs1 = IonoObs.changeset(%IonoObs{}, %{valid_time: ~U[2026-04-21 00:00:00Z]})
cs2 = IonoObs.changeset(%IonoObs{}, %{station_code: "BC840"})
refute cs1.valid?
refute cs2.valid?
assert %{station_code: ["can't be blank"]} = errors_on(cs1)
assert %{valid_time: ["can't be blank"]} = errors_on(cs2)
end
end
describe "HrrrClimatology.changeset/2" do
test "valid with the four required keys" do
cs =
HrrrClimatology.changeset(%HrrrClimatology{}, %{
lat: 32.9,
lon: -97.0,
month: 7,
hour: 15
})
assert cs.valid?
end
test "casts mean / stddev / sample_count" do
cs =
HrrrClimatology.changeset(%HrrrClimatology{}, %{
lat: 32.9,
lon: -97.0,
month: 7,
hour: 15,
mean_surface_temp_c: 30.5,
stddev_surface_temp_c: 4.2,
sample_count: 365
})
assert cs.valid?
assert Ecto.Changeset.get_change(cs, :mean_surface_temp_c) == 30.5
assert Ecto.Changeset.get_change(cs, :sample_count) == 365
end
test "invalid when any of the four cell-identity keys is missing" do
for missing <- [:lat, :lon, :month, :hour] do
attrs = Map.delete(%{lat: 32.9, lon: -97.0, month: 7, hour: 15}, missing)
cs = HrrrClimatology.changeset(%HrrrClimatology{}, attrs)
refute cs.valid?, "expected #{missing} missing to invalidate"
assert errors_on(cs)[missing] == ["can't be blank"]
end
end
end
describe "Metar5minObservation.changeset/2" do
@station_id Ecto.UUID.generate()
test "valid with station_id + observed_at" do
cs =
Metar5minObservation.changeset(%Metar5minObservation{}, %{
station_id: @station_id,
observed_at: ~U[2026-04-21 00:00:00Z]
})
assert cs.valid?
end
test "casts every observation field" do
cs =
Metar5minObservation.changeset(%Metar5minObservation{}, %{
station_id: @station_id,
observed_at: ~U[2026-04-21 00:05:00Z],
temp_f: 72.5,
dewpoint_f: 55.0,
relative_humidity: 55.0,
wind_speed_kts: 8.0,
wind_direction_deg: 180,
sea_level_pressure_mb: 1013.2,
altimeter_setting: 29.92,
sky_condition: "SCT030",
precip_1h_in: 0.0,
wx_codes: "VCTS"
})
assert cs.valid?
end
test "invalid without station_id" do
cs = Metar5minObservation.changeset(%Metar5minObservation{}, %{observed_at: ~U[2026-04-21 00:00:00Z]})
refute cs.valid?
assert errors_on(cs)[:station_id] == ["can't be blank"]
end
test "invalid without observed_at" do
cs = Metar5minObservation.changeset(%Metar5minObservation{}, %{station_id: @station_id})
refute cs.valid?
assert errors_on(cs)[:observed_at] == ["can't be blank"]
end
end
# Local copy of the standard Phoenix.DataCase `errors_on/1` so this
# file can stay on the lightweight `ExUnit.Case` (no DB sandbox
# needed for changeset tests).
defp errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end