64 unit tests + 10 property tests across three parallel agents. - ContactLive.Show round 3 (72% → ~80%): enrichment failure surfaces (terrain/iemre/weather :failed, hrrr :unavailable NARR fallback), radar-only mechanism, load_solar + enqueue_missing_solar paths, blocked_message nil obs_dist, band_summary for 144M/1296M/ 2304M/47G/75G, admin edit no-op, mode changes, fetch_queue_counts with seeded Oban jobs. Properties: propagation_mechanism summary is always a binary, obs_sort_key totality over arbitrary field keys. - Weather clients: iem_client transport timeouts + CSV edge cases, rtma_client idx url property, swpc_client HTTP 503/404 + empty- array parses + JSON/CSV totality properties, ncei_metar_client parse edge cases, snmp_client OID-roundtrip property + parse edge cases. - Mid-coverage LiveViews: UserProfileLive avatar/render branches, EmeLive invalid-grid/unknown-callsign + form-validation property, PathLive zero-distance + 3000km + height/power round-trip property, BeaconLive numeric bearing + non-admin approve denied, MapLive select_band/toggle_radar/toggle_grid events + band-URL property. Fix: propagation_mechanism property test uses System.unique_integer for lat + valid_time nonces instead of StreamData-shrinkable jitter; the shrink toward 0 was tripping the hrrr_profiles unique index. 205 → 215 properties, 2743 → 2812 tests, 0 failures.
131 lines
5.1 KiB
Elixir
131 lines
5.1 KiB
Elixir
defmodule Microwaveprop.Weather.NceiMetarClientTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Weather.NceiMetarClient
|
|
|
|
@sample_line "03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094"
|
|
|
|
describe "parse/1" do
|
|
test "parses a single KDFW observation" do
|
|
[obs] = NceiMetarClient.parse(@sample_line)
|
|
|
|
assert obs.icao == "KDFW"
|
|
assert obs.observed_at == ~U[2026-03-01 00:00:00Z]
|
|
# T02110094 → 21.1°C = 70.0°F, 9.4°C = 48.9°F
|
|
assert_in_delta obs.temp_f, 70.0, 0.2
|
|
assert_in_delta obs.dewpoint_f, 48.9, 0.2
|
|
assert obs.wind_speed_kts == 12.0
|
|
assert obs.wind_direction_deg == 170
|
|
assert_in_delta obs.altimeter_setting, 29.97, 0.01
|
|
assert obs.sky_condition == "CLR"
|
|
end
|
|
|
|
test "parses multiple lines" do
|
|
text = """
|
|
03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094
|
|
03927KDFW DFW20260301000510303/01/26 00:05:31 5-MIN KDFW 010605Z 17012KT 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094
|
|
"""
|
|
|
|
obs = NceiMetarClient.parse(text)
|
|
assert length(obs) == 2
|
|
assert Enum.at(obs, 0).observed_at == ~U[2026-03-01 00:00:00Z]
|
|
assert Enum.at(obs, 1).observed_at == ~U[2026-03-01 00:05:00Z]
|
|
end
|
|
|
|
test "skips blank or too-short lines" do
|
|
assert NceiMetarClient.parse("") == []
|
|
assert NceiMetarClient.parse("short\n\n") == []
|
|
end
|
|
|
|
test "handles negative temperatures via M prefix" do
|
|
line =
|
|
"03927KDFW DFW20260115120010103/15/26 12:00:31 5-MIN KDFW 151200Z 36005KT 10SM CLR M02/M08 A3032 560 47 1400 360/05 RMK AO2"
|
|
|
|
[obs] = NceiMetarClient.parse(line)
|
|
|
|
# M02 = -2°C = 28.4°F, M08 = -8°C = 17.6°F
|
|
assert_in_delta obs.temp_f, 28.4, 0.2
|
|
assert_in_delta obs.dewpoint_f, 17.6, 0.2
|
|
end
|
|
|
|
test "extracts wind speed/direction from the VVVKKT group" do
|
|
# 17015KT = 170° at 15 kt.
|
|
line =
|
|
"03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17015KT 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094"
|
|
|
|
[obs] = NceiMetarClient.parse(line)
|
|
assert obs.wind_direction_deg == 170
|
|
assert obs.wind_speed_kts == 15.0
|
|
end
|
|
|
|
test "returns nil wind values when the wind group is missing" do
|
|
# Valid METAR without a wind group (CALM-ish). extract_wind_* should
|
|
# fall through to nil rather than crashing.
|
|
line =
|
|
"03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094"
|
|
|
|
[obs] = NceiMetarClient.parse(line)
|
|
assert obs.wind_speed_kts == nil
|
|
assert obs.wind_direction_deg == nil
|
|
end
|
|
|
|
test "concatenates multiple sky tokens" do
|
|
# Layered ceiling: SCT030 + BKN080.
|
|
line =
|
|
"03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM SCT030 BKN080 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094"
|
|
|
|
[obs] = NceiMetarClient.parse(line)
|
|
assert obs.sky_condition =~ "SCT030"
|
|
assert obs.sky_condition =~ "BKN080"
|
|
end
|
|
|
|
test "sky_condition is nil when no cloud-layer token is present" do
|
|
# No CLR/FEW/SCT/BKN/OVC/VV token at all.
|
|
line =
|
|
"03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM 21/09 A2997 560 47 1400 160/12 RMK AO2 T02110094"
|
|
|
|
[obs] = NceiMetarClient.parse(line)
|
|
assert obs.sky_condition == nil
|
|
end
|
|
|
|
test "altimeter_setting is nil when the A-group is absent" do
|
|
line =
|
|
"03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM CLR 21/09 560 47 1400 160/12 RMK AO2 T02110094"
|
|
|
|
[obs] = NceiMetarClient.parse(line)
|
|
assert obs.altimeter_setting == nil
|
|
end
|
|
|
|
test "skips a long line whose date/time header is garbage" do
|
|
# ≥60 bytes so parse_line/1 doesn't short-circuit on the length guard,
|
|
# but the date/time fields are non-numeric — parse_datetime/2 returns
|
|
# :error and the line is dropped.
|
|
line = String.duplicate("X", 80)
|
|
assert NceiMetarClient.parse(line) == []
|
|
end
|
|
|
|
test "parse/1 returns [] for a single trailing newline" do
|
|
assert NceiMetarClient.parse("\n") == []
|
|
end
|
|
|
|
test "parse/1 drops a line that is exactly 60 bytes of padding with an invalid date" do
|
|
# 5 WBAN + 4 ICAO + 4 FAA + 8 date + 4 hhmm = 25 bytes of header,
|
|
# 60 bytes total to satisfy the length guard.
|
|
line = String.duplicate("A", 60)
|
|
assert NceiMetarClient.parse(line) == []
|
|
end
|
|
|
|
test "falls back to the coarse temp/dew pair when no T-group is in the remark" do
|
|
# No T-group → extract_temp_f falls through to the "21/09" field,
|
|
# which gives integer °C temperatures.
|
|
line =
|
|
"03927KDFW DFW20260301000010303/01/26 00:00:31 5-MIN KDFW 010600Z 17012KT 10SM CLR 21/09 A2997 560 47 1400 160/12 RMK AO2"
|
|
|
|
[obs] = NceiMetarClient.parse(line)
|
|
# 21°C = 69.8°F, 9°C = 48.2°F — coarser than the T-group but
|
|
# within ±0.3°F.
|
|
assert_in_delta obs.temp_f, 69.8, 0.3
|
|
assert_in_delta obs.dewpoint_f, 48.2, 0.3
|
|
end
|
|
end
|
|
end
|