test: cover Format.number/bytes, Instrument.span, SolarClient edges
- 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.
This commit is contained in:
parent
8324d019ca
commit
41573d4013
3 changed files with 231 additions and 0 deletions
|
|
@ -54,4 +54,86 @@ defmodule Microwaveprop.FormatPropertyTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "number/1" do
|
||||||
|
test "inserts commas every three digits from the right" do
|
||||||
|
assert Format.number(0) == "0"
|
||||||
|
assert Format.number(999) == "999"
|
||||||
|
assert Format.number(1_000) == "1,000"
|
||||||
|
assert Format.number(1_234_567) == "1,234,567"
|
||||||
|
assert Format.number(-1_234_567) == "-1,234,567"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rounds floats to an integer before formatting" do
|
||||||
|
assert Format.number(1000.4) == "1,000"
|
||||||
|
assert Format.number(1000.6) == "1,001"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls through to string conversion for non-numeric input" do
|
||||||
|
# The scorecard table occasionally hits nil / atom cells; we'd
|
||||||
|
# rather stringify than crash the LiveView.
|
||||||
|
assert Format.number(nil) == ""
|
||||||
|
assert Format.number(:missing) == "missing"
|
||||||
|
end
|
||||||
|
|
||||||
|
property "separator insertion is lossless: stripping commas round-trips to the input" do
|
||||||
|
check all(n <- integer(-1_000_000_000..1_000_000_000)) do
|
||||||
|
formatted = Format.number(n)
|
||||||
|
assert formatted |> String.replace(",", "") |> String.to_integer() == n
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "output has a comma every 4th position walking backward from the end (for |n| ≥ 1000)" do
|
||||||
|
check all(n <- integer(1000..999_999_999)) do
|
||||||
|
formatted = Format.number(n)
|
||||||
|
# Walk from the end in 4-char windows (digit, digit, digit, comma).
|
||||||
|
digits_only = String.replace(formatted, ",", "")
|
||||||
|
expected_commas = div(String.length(digits_only) - 1, 3)
|
||||||
|
assert formatted |> String.graphemes() |> Enum.filter(&(&1 == ",")) |> length() == expected_commas
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "bytes/1" do
|
||||||
|
test "picks the right unit at each boundary" do
|
||||||
|
assert Format.bytes(0) == "0 B"
|
||||||
|
assert Format.bytes(1023) == "1023 B"
|
||||||
|
assert Format.bytes(1024) == "1.0 KB"
|
||||||
|
assert Format.bytes(1_500_000) == "1.4 MB"
|
||||||
|
assert Format.bytes(2_147_483_648) == "2.0 GB"
|
||||||
|
end
|
||||||
|
|
||||||
|
property "output always ends with a unit suffix (B, KB, MB, or GB)" do
|
||||||
|
check all(b <- integer(0..10_000_000_000)) do
|
||||||
|
s = Format.bytes(b)
|
||||||
|
assert s =~ ~r/\s(B|KB|MB|GB)\z/
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
property "output is monotonic non-decreasing in the input when parsed back" do
|
||||||
|
# Specifically: converting the formatted string back to bytes
|
||||||
|
# preserves ordering. Edge cases around unit boundaries matter:
|
||||||
|
# 1023 B < 1024 B (= 1.0 KB) < 2048 B (= 2.0 KB).
|
||||||
|
check all(
|
||||||
|
a <- integer(0..10_000_000_000),
|
||||||
|
delta <- integer(0..10_000_000_000)
|
||||||
|
) do
|
||||||
|
b = a + delta
|
||||||
|
assert parse_bytes(Format.bytes(a)) <= parse_bytes(Format.bytes(b)) + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_bytes(s) do
|
||||||
|
# Reverse of Format.bytes/1 for monotonicity checking.
|
||||||
|
{n, rest} = Float.parse(s)
|
||||||
|
unit = rest |> String.trim() |> String.upcase()
|
||||||
|
|
||||||
|
case unit do
|
||||||
|
"B" -> round(n)
|
||||||
|
"KB" -> round(n * 1024)
|
||||||
|
"MB" -> round(n * 1_048_576)
|
||||||
|
"GB" -> round(n * 1_073_741_824)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
89
test/microwaveprop/instrument_test.exs
Normal file
89
test/microwaveprop/instrument_test.exs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
defmodule Microwaveprop.InstrumentTest do
|
||||||
|
@moduledoc """
|
||||||
|
Cover the `Instrument.span/3` wrapper directly. It's an unassuming
|
||||||
|
5-line function but it's the emit point every call-site telemetry
|
||||||
|
handler keys off, so regressions here are invisible at the call
|
||||||
|
site but silently break metrics.
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: false
|
||||||
|
|
||||||
|
alias Microwaveprop.Instrument
|
||||||
|
|
||||||
|
setup do
|
||||||
|
test_pid = self()
|
||||||
|
|
||||||
|
handler_id = {__MODULE__, :instrument_handler, System.unique_integer([:positive])}
|
||||||
|
|
||||||
|
:telemetry.attach_many(
|
||||||
|
handler_id,
|
||||||
|
[
|
||||||
|
[:microwaveprop, :test, :start],
|
||||||
|
[:microwaveprop, :test, :stop],
|
||||||
|
[:microwaveprop, :test, :exception]
|
||||||
|
],
|
||||||
|
fn event, measurements, metadata, _config ->
|
||||||
|
send(test_pid, {:telemetry, event, measurements, metadata})
|
||||||
|
end,
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
|
||||||
|
on_exit(fn -> :telemetry.detach(handler_id) end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "wraps the function in a telemetry span and returns its value" do
|
||||||
|
assert 42 == Instrument.span([:test], %{flavour: :unit}, fn -> 42 end)
|
||||||
|
|
||||||
|
assert_receive {:telemetry, [:microwaveprop, :test, :start], %{}, %{flavour: :unit}}
|
||||||
|
assert_receive {:telemetry, [:microwaveprop, :test, :stop], %{duration: dur}, stop_meta}
|
||||||
|
|
||||||
|
assert dur >= 0
|
||||||
|
assert stop_meta.flavour == :unit
|
||||||
|
assert stop_meta.result == :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "supports metadata default of %{}" do
|
||||||
|
assert :ok == Instrument.span([:test], fn -> :ok end)
|
||||||
|
|
||||||
|
assert_receive {:telemetry, [:microwaveprop, :test, :start], %{}, %{}}
|
||||||
|
assert_receive {:telemetry, [:microwaveprop, :test, :stop], %{duration: _}, stop_meta}
|
||||||
|
|
||||||
|
assert stop_meta.result == :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raising inside the span emits an exception event and re-raises" do
|
||||||
|
assert_raise RuntimeError, "boom", fn ->
|
||||||
|
Instrument.span([:test], %{}, fn -> raise "boom" end)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_receive {:telemetry, [:microwaveprop, :test, :start], _, _}
|
||||||
|
assert_receive {:telemetry, [:microwaveprop, :test, :exception], %{duration: _}, exception_meta}
|
||||||
|
# :telemetry.span/3 adds `kind`, `reason`, `stacktrace` keys on the
|
||||||
|
# exception branch — assert the ones worth relying on.
|
||||||
|
assert exception_meta.kind == :error
|
||||||
|
assert %RuntimeError{message: "boom"} = exception_meta.reason
|
||||||
|
end
|
||||||
|
|
||||||
|
test "prefixes every event suffix with :microwaveprop" do
|
||||||
|
# The whole point of the wrapper is that call sites pass [:foo, :bar]
|
||||||
|
# and get telemetry rooted at [:microwaveprop, :foo, :bar]. Verify by
|
||||||
|
# attaching a separate handler on a distinct suffix.
|
||||||
|
test_pid = self()
|
||||||
|
handler_id = {__MODULE__, :prefix_check, System.unique_integer([:positive])}
|
||||||
|
|
||||||
|
:telemetry.attach(
|
||||||
|
handler_id,
|
||||||
|
[:microwaveprop, :prefix, :check, :stop],
|
||||||
|
fn event, _m, _md, _c -> send(test_pid, {:evt, event}) end,
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
|
||||||
|
try do
|
||||||
|
:ok = Instrument.span([:prefix, :check], fn -> :ok end)
|
||||||
|
assert_receive {:evt, [:microwaveprop, :prefix, :check, :stop]}
|
||||||
|
after
|
||||||
|
:telemetry.detach(handler_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -97,4 +97,64 @@ defmodule Microwaveprop.Weather.SolarClientTest do
|
||||||
assert SolarClient.data_url() == "https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt"
|
assert SolarClient.data_url() == "https://kp.gfz.de/app/files/Kp_ap_Ap_SN_F107_since_1932.txt"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue