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