From 41573d4013658fce2f016aef5cbb4821f2aeea13 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 23 Apr 2026 14:09:12 -0500 Subject: [PATCH] test: cover Format.number/bytes, Instrument.span, SolarClient edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- test/microwaveprop/format_property_test.exs | 82 +++++++++++++++++ test/microwaveprop/instrument_test.exs | 89 +++++++++++++++++++ .../weather/solar_client_test.exs | 60 +++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 test/microwaveprop/instrument_test.exs diff --git a/test/microwaveprop/format_property_test.exs b/test/microwaveprop/format_property_test.exs index 6b270975..f0b44776 100644 --- a/test/microwaveprop/format_property_test.exs +++ b/test/microwaveprop/format_property_test.exs @@ -54,4 +54,86 @@ defmodule Microwaveprop.FormatPropertyTest do 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 diff --git a/test/microwaveprop/instrument_test.exs b/test/microwaveprop/instrument_test.exs new file mode 100644 index 00000000..49382d7f --- /dev/null +++ b/test/microwaveprop/instrument_test.exs @@ -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 diff --git a/test/microwaveprop/weather/solar_client_test.exs b/test/microwaveprop/weather/solar_client_test.exs index e43e2312..18aa5a8f 100644 --- a/test/microwaveprop/weather/solar_client_test.exs +++ b/test/microwaveprop/weather/solar_client_test.exs @@ -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" 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