- 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.
139 lines
4.7 KiB
Elixir
139 lines
4.7 KiB
Elixir
defmodule Microwaveprop.FormatPropertyTest do
|
|
use ExUnit.Case, async: true
|
|
use ExUnitProperties
|
|
|
|
alias Microwaveprop.Format
|
|
|
|
# Any finite non-negative number is legitimate input for distance_km/1.
|
|
# StreamData floats default to the full representable range — we keep
|
|
# that but reject negatives since negative distances are meaningless
|
|
# (the algorithm callers always pass an already-absolute km value).
|
|
defp non_negative_number do
|
|
one_of([
|
|
integer(0..1_000_000_000),
|
|
map(float(min: 0.0), fn f -> f end),
|
|
map(integer(0..1_000_000), &(&1 * 1.0))
|
|
])
|
|
end
|
|
|
|
describe "distance_km/1" do
|
|
property "returns a binary for nil" do
|
|
# Not really a property, just pinned for discoverability alongside
|
|
# the numeric properties.
|
|
assert Format.distance_km(nil) == "—"
|
|
end
|
|
|
|
property "always returns a non-empty binary for any non-negative number" do
|
|
check all(n <- non_negative_number()) do
|
|
result = Format.distance_km(n)
|
|
assert is_binary(result)
|
|
assert result != ""
|
|
end
|
|
end
|
|
|
|
property "output contains both `mi` and `km` units" do
|
|
check all(n <- non_negative_number()) do
|
|
result = Format.distance_km(n)
|
|
assert result =~ "mi"
|
|
assert result =~ "km"
|
|
end
|
|
end
|
|
|
|
property "accepts Decimal input without crashing" do
|
|
check all(n <- integer(0..10_000)) do
|
|
decimal = Decimal.new(n)
|
|
assert is_binary(Format.distance_km(decimal))
|
|
end
|
|
end
|
|
|
|
property "accepts negative distances without crashing" do
|
|
# Not a valid input at the call site, but we don't want arbitrary
|
|
# numeric data (e.g. a sign slip in a caller) to crash the view.
|
|
check all(n <- float(min: -1.0e6, max: -0.01)) do
|
|
assert is_binary(Format.distance_km(n))
|
|
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
|