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