- Format.number/1: boundary cases (0, 999, 1_000), float rounding,
nil/atom fallback to to_string, plus two properties — comma
insertion is lossless round-trip and the comma count follows
`div(digits - 1, 3)` for |n| ≥ 1000.
- Format.bytes/1: per-unit boundary cases plus a monotonicity
property (parse-back preserves ordering across the B/KB/MB/GB
transitions).
- Instrument.span/2,3 (0→100%): wraps the block in a telemetry span
returning its value, defaults metadata to %{}, emits the
:exception event and re-raises on a raise inside the span, and
prefixes every event suffix with :microwaveprop.
- SolarClient: short-line / empty-line / blank-day handling for
parse_gfz_row; blank-line + short-line tolerance in parse_gfz_file;
filter_since boundary semantics (>= is inclusive, empty list
passes through).
Suite: 2,397 tests + 159 properties (was 2,382 + 155); coverage
71.12% → 71.16%; Format + Instrument now at 100%; credo strict clean.
160 lines
6.1 KiB
Elixir
160 lines
6.1 KiB
Elixir
defmodule Microwaveprop.Weather.SolarClientTest do
|
|
use ExUnit.Case, async: true
|
|
|
|
alias Microwaveprop.Weather.SolarClient
|
|
|
|
@sample_gfz_file """
|
|
# Comment line 1
|
|
# Comment line 2
|
|
# YYYY MM DD days days_m Bsr dB Kp1 Kp2 Kp3 Kp4 Kp5 Kp6 Kp7 Kp8 ap1 ap2 ap3 ap4 ap5 ap6 ap7 ap8 Ap SN F10.7obs F10.7adj D
|
|
2024 06 15 33769 33769.5 2589 18 2.000 1.667 2.333 3.000 2.667 1.333 1.000 1.667 7 6 9 15 12 5 4 6 8 120 150.2 148.5 2
|
|
2024 06 16 33770 33770.5 2589 19 3.333 4.000 2.667 1.000 0.667 1.333 2.000 1.667 18 27 12 4 3 5 7 6 10 115 145.0 143.2 2
|
|
"""
|
|
|
|
@sample_row_with_missing "1932 01 01 0 0.5 1352 10 3.333 2.667 2.333 2.667 3.333 2.667 3.333 3.333 18 12 9 12 18 12 18 18 15 22 -1.0 -1.0 2"
|
|
|
|
@sample_row_all_missing "1940 05 01 3043 3043.5 1385 23 -1.000 -1.000 -1.000 -1.000 -1.000 -1.000 -1.000 -1.000 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1.0 -1.0 0"
|
|
|
|
describe "parse_gfz_row/1" do
|
|
test "parses a complete data row" do
|
|
row =
|
|
"2024 06 15 33769 33769.5 2589 18 2.000 1.667 2.333 3.000 2.667 1.333 1.000 1.667 7 6 9 15 12 5 4 6 8 120 150.2 148.5 2"
|
|
|
|
assert {:ok, parsed} = SolarClient.parse_gfz_row(row)
|
|
assert parsed.date == ~D[2024-06-15]
|
|
assert parsed.sfi == 150.2
|
|
assert parsed.sfi_adjusted == 148.5
|
|
assert parsed.sunspot_number == 120
|
|
assert parsed.ap_index == 8
|
|
assert parsed.kp_values == [2.0, 1.667, 2.333, 3.0, 2.667, 1.333, 1.0, 1.667]
|
|
end
|
|
|
|
test "maps -1 values to nil for F10.7" do
|
|
assert {:ok, parsed} = SolarClient.parse_gfz_row(@sample_row_with_missing)
|
|
assert parsed.date == ~D[1932-01-01]
|
|
assert is_nil(parsed.sfi)
|
|
assert is_nil(parsed.sfi_adjusted)
|
|
assert parsed.sunspot_number == 22
|
|
assert parsed.ap_index == 15
|
|
end
|
|
|
|
test "maps all -1 values to nil" do
|
|
assert {:ok, parsed} = SolarClient.parse_gfz_row(@sample_row_all_missing)
|
|
assert parsed.date == ~D[1940-05-01]
|
|
assert is_nil(parsed.sfi)
|
|
assert is_nil(parsed.sfi_adjusted)
|
|
assert is_nil(parsed.sunspot_number)
|
|
assert is_nil(parsed.ap_index)
|
|
assert parsed.kp_values == [nil, nil, nil, nil, nil, nil, nil, nil]
|
|
end
|
|
end
|
|
|
|
describe "parse_gfz_file/1" do
|
|
test "skips comment lines and parses data rows" do
|
|
result = SolarClient.parse_gfz_file(@sample_gfz_file)
|
|
assert length(result) == 2
|
|
|
|
[first, second] = result
|
|
assert first.date == ~D[2024-06-15]
|
|
assert first.sfi == 150.2
|
|
assert second.date == ~D[2024-06-16]
|
|
assert second.sfi == 145.0
|
|
end
|
|
|
|
test "returns empty list for empty input" do
|
|
assert SolarClient.parse_gfz_file("") == []
|
|
end
|
|
|
|
test "returns empty list for comments-only input" do
|
|
assert SolarClient.parse_gfz_file("# just a comment\n# another") == []
|
|
end
|
|
end
|
|
|
|
describe "fetch_solar_indices_since/1" do
|
|
test "filters records to those on or after the given date" do
|
|
records = SolarClient.parse_gfz_file(@sample_gfz_file)
|
|
# Records are 2024-06-15 and 2024-06-16
|
|
filtered = SolarClient.filter_since(records, ~D[2024-06-16])
|
|
assert length(filtered) == 1
|
|
assert hd(filtered).date == ~D[2024-06-16]
|
|
end
|
|
|
|
test "returns all records when since_date is before all records" do
|
|
records = SolarClient.parse_gfz_file(@sample_gfz_file)
|
|
filtered = SolarClient.filter_since(records, ~D[2020-01-01])
|
|
assert length(filtered) == 2
|
|
end
|
|
|
|
test "returns empty list when since_date is after all records" do
|
|
records = SolarClient.parse_gfz_file(@sample_gfz_file)
|
|
filtered = SolarClient.filter_since(records, ~D[2025-01-01])
|
|
assert filtered == []
|
|
end
|
|
end
|
|
|
|
describe "data_url/0" do
|
|
test "returns the GFZ data URL" do
|
|
assert SolarClient.data_url() == "https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt"
|
|
end
|
|
end
|
|
|
|
describe "parse_gfz_row/1 edge cases" do
|
|
test "returns :error when the line has fewer than 28 fields" do
|
|
# Truncated row: just YYYY MM DD with no observation data.
|
|
assert SolarClient.parse_gfz_row("2024 06 15 33769") == :error
|
|
end
|
|
|
|
test "returns :error on an empty line" do
|
|
assert SolarClient.parse_gfz_row("") == :error
|
|
end
|
|
|
|
test "maps -1 sentinel values to nil for every optional field" do
|
|
row = @sample_row_all_missing
|
|
assert {:ok, parsed} = SolarClient.parse_gfz_row(row)
|
|
assert Enum.all?(parsed.kp_values, &is_nil/1)
|
|
assert is_nil(parsed.sfi)
|
|
assert is_nil(parsed.sfi_adjusted)
|
|
assert is_nil(parsed.sunspot_number)
|
|
assert is_nil(parsed.ap_index)
|
|
end
|
|
|
|
test "raises on non-numeric date fields (file is assumed well-formed)" do
|
|
# Bad date — the module uses String.to_integer which raises, and
|
|
# the caller catches :error only via the short-line path. This
|
|
# test pins that contract so a future change either keeps the
|
|
# raise OR converts it to :error intentionally.
|
|
bad =
|
|
"abcd 06 15 33769 33769.5 2589 18 2.000 1.667 2.333 3.000 2.667 1.333 1.000 1.667 7 6 9 15 12 5 4 6 8 120 150.2 148.5 2"
|
|
|
|
assert_raise ArgumentError, fn -> SolarClient.parse_gfz_row(bad) end
|
|
end
|
|
end
|
|
|
|
describe "parse_gfz_file/1 robustness" do
|
|
test "skips blank lines, comment lines, and short lines" do
|
|
text = """
|
|
|
|
# comment 1
|
|
# comment 2
|
|
|
|
2024 06 15 33769
|
|
2024 06 15 33769 33769.5 2589 18 2.000 1.667 2.333 3.000 2.667 1.333 1.000 1.667 7 6 9 15 12 5 4 6 8 120 150.2 148.5 2
|
|
"""
|
|
|
|
assert [row] = SolarClient.parse_gfz_file(text)
|
|
assert row.date == ~D[2024-06-15]
|
|
end
|
|
end
|
|
|
|
describe "filter_since/2 boundary behaviour" do
|
|
test "includes the boundary day itself (>= semantics)" do
|
|
records = SolarClient.parse_gfz_file(@sample_gfz_file)
|
|
filtered = SolarClient.filter_since(records, ~D[2024-06-15])
|
|
assert length(filtered) == 2
|
|
end
|
|
|
|
test "accepts an already-empty record list" do
|
|
assert SolarClient.filter_since([], ~D[2024-06-15]) == []
|
|
end
|
|
end
|
|
end
|