Files
superset2/tests/unit_tests/utils/number_format_test.py
T

563 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from decimal import Decimal
from typing import Any
from unittest.mock import MagicMock, patch
import pandas as pd
import pytest
from flask import current_app
from superset.utils.number_format import (
format_d3,
format_default,
format_number_with_config,
format_numeric,
get_currency_locale,
resolve_auto_currency,
resolve_symbol_position,
)
# --- Helper behaviour the d3 parity matrix below cannot cover ----------------
@pytest.mark.parametrize(
"d3_format,value,expected",
[
# SMART_NUMBER is a Superset formatter (adaptive SI, half-up), not raw d3
("SMART_NUMBER", 4725, "4.73k"),
("SMART_NUMBER", 80679663, "80.7M"),
("SMART_NUMBER", 1234567890, "1.23B"),
("SMART_NUMBER", 0, "0"),
(".2~f", 1200.0, "1200"),
(None, 42, "42"),
("not-a-real-format!!", 42, "42"),
],
)
def test_format_number(d3_format: str | None, value: Any, expected: Any) -> None:
assert format_number_with_config(d3_format, None, value) == expected
@pytest.mark.parametrize(
"currency,value,expected",
[
({"symbol": "USD", "symbolPosition": "prefix"}, 1234.5, "$ 1,234.50"),
({"symbol": "EUR", "symbolPosition": "suffix"}, 1234.5, "1,234.50 €"),
({"symbol": "BRL", "symbolPosition": None}, 1234.5, "R$ 1,234.50"),
({"symbol": "ZZZ", "symbolPosition": None}, 1234.5, "ZZZ 1,234.50"),
],
)
def test_format_number_with_currency(
currency: dict[str, Any], value: float, expected: str
) -> None:
assert format_number_with_config(",.2f", currency, value) == expected
def test_currency_defaults_to_smart_number_when_no_d3_format() -> None:
assert (
format_number_with_config(
None, {"symbol": "USD", "symbolPosition": "prefix"}, 1234567
)
== "$ 1.23M"
)
def test_auto_currency_formats_without_symbol() -> None:
assert (
format_number_with_config(
",.2f", {"symbol": "AUTO", "symbolPosition": "prefix"}, 1234.5
)
== "1,234.50"
)
def test_resolve_auto_currency_uses_detected_single_currency() -> None:
currency = {"symbol": "AUTO", "symbolPosition": "prefix"}
assert resolve_auto_currency(currency, "USD") == {
"symbol": "USD",
"symbolPosition": "prefix",
}
assert resolve_auto_currency(currency, None) is currency
explicit = {"symbol": "EUR", "symbolPosition": "suffix"}
assert resolve_auto_currency(explicit, "USD") is explicit
def test_resolve_auto_currency_prefers_cell_context_and_detects_mixed() -> None:
currency = {"symbol": "AUTO", "symbolPosition": "prefix"}
assert resolve_auto_currency(
currency, "GBP", currency_context=frozenset({" usd "})
) == {"symbol": "USD", "symbolPosition": "prefix"}
assert (
resolve_auto_currency(
currency, "GBP", currency_context=frozenset({"USD", "EUR"})
)
is currency
)
assert resolve_auto_currency(currency, "GBP", currency_context=frozenset()) == {
"symbol": "GBP",
"symbolPosition": "prefix",
}
assert (
resolve_auto_currency(
currency,
"GBP",
currency_context=frozenset(),
fallback_to_detected=False,
)
is currency
)
@pytest.mark.parametrize("missing", [float("nan"), pd.NA, pd.NaT])
def test_resolve_auto_currency_coerces_non_iterable_context(missing: Any) -> None:
"""
A sparse 2D pivot leaves missing cells as a scalar missing value rather than
an empty tuple. AUTO resolution must treat any non-iterable context (the
``np.nan`` the live path produces, plus other pandas sentinels) as empty
instead of raising ``TypeError`` on ``list(context)``. ``missing`` is typed
``Any`` on purpose: it pins the runtime guard without widening the narrow
``Iterable[Any] | float | None`` contract to cover these sentinels.
"""
currency = {"symbol": "AUTO", "symbolPosition": "prefix"}
# A missing context with fallback enabled behaves like an empty context and
# uses the query-wide detected currency.
assert resolve_auto_currency(currency, "GBP", currency_context=missing) == {
"symbol": "GBP",
"symbolPosition": "prefix",
}
# A missing context without fallback keeps AUTO, like the empty-context path.
assert (
resolve_auto_currency(
currency,
"GBP",
currency_context=missing,
fallback_to_detected=False,
)
is currency
)
@pytest.mark.parametrize(
"value,expected",
[
(5e-7, "5e-7"),
(9.999999e-7, "9.999999e-7"),
(1e-6, "0.000001"),
(999_999_999_999, "999,999,999,999"),
(1e12, "1e+12"),
(1e20, "1e+20"),
(999_999_999_999_999_900_000, "1e+21"),
(1e21, "1e+21"),
],
)
def test_default_format_matches_d3_exponent_boundaries(
value: float, expected: str
) -> None:
assert format_number_with_config(",", None, value) == expected
def test_currency_position_uses_request_locale() -> None:
with patch("superset.utils.number_format.get_locale", return_value="fr_FR"):
assert (
format_number_with_config(
",.2f", {"symbol": "EUR", "symbolPosition": None}, 1234.5
)
== "1,234.50 €"
)
def test_currency_position_uses_configured_locale_without_request() -> None:
with (
patch("superset.utils.number_format.get_locale", return_value=None),
patch.dict(current_app.config, {"BABEL_DEFAULT_LOCALE": "fr_FR"}),
):
assert (
format_number_with_config(
",.2f", {"symbol": "EUR", "symbolPosition": None}, 1234.5
)
== "1,234.50 €"
)
@pytest.mark.parametrize(
"code,locale,expected",
[
("USD", "en_US", "prefix"),
("EUR", "fr_FR", "suffix"),
("ZZZ", "not_a_locale", "prefix"),
],
)
def test_resolve_symbol_position(code: str, locale: str, expected: str) -> None:
resolve_symbol_position.cache_clear()
assert resolve_symbol_position(code, locale) == expected
def test_get_currency_locale_handles_missing_babel_and_app_context() -> None:
app = MagicMock()
with (
patch("superset.utils.number_format.get_locale", side_effect=RuntimeError),
patch("superset.utils.number_format.current_app", app),
):
app.config.get.return_value = "de_DE"
assert get_currency_locale() == "de_DE"
app.config.get.side_effect = RuntimeError
assert get_currency_locale() == "en"
def test_non_numeric_value_is_returned_as_is() -> None:
assert format_number_with_config(",.2f", None, "abc") == "abc"
assert format_number_with_config(",.2f", None, None) == ""
def test_currency_error_keeps_formatted_number() -> None:
assert (
format_number_with_config(
",.2f", {"symbol": {"bad": 1}, "symbolPosition": "prefix"}, 1234.5
)
== "1,234.50"
)
def test_decimal_values_are_formatted() -> None:
assert format_number_with_config(",.2f", None, Decimal("1234.5")) == "1,234.50"
assert (
format_number_with_config(
",.2f", {"symbol": "USD", "symbolPosition": "prefix"}, Decimal("1234.5")
)
== "$ 1,234.50"
)
assert format_number_with_config(",.2f", None, Decimal("NaN")) == ""
# --- Parity with the frontend d3-format --------------------------------------
#
# EXPECTED is the authoritative output of the frontend's ``d3-format`` (the same
# library the Table/Pivot charts render with) for every number preset in
# ``D3_FORMAT_OPTIONS``. Regenerate from ``superset-frontend`` with::
#
# node -e 'const {format}=require("d3-format");
# const p=["~g",",d",".1s",".3s",",.1%",".2%",".3%",".4r",
# ",.1f",",.2f",",.3f","+,","$,.2f"];
# const v=[12345.432,0,4725.0,80679663,1234567890,-1234.5,
# 0.0123,999.9,1000.0];
# const o={}; for(const f of p){o[f]={};
# for(const x of v) o[f][x]=format(f)(x);}
# console.log(JSON.stringify(o));'
#
# One intentional deviation: d3 emits a Unicode minus (U+2212); the Python helper
# emits an ASCII "-" for email/CSV safety, so the comparison normalizes it.
VALUES: list[float] = [
12345.432,
0,
4725.0,
80679663,
1234567890,
-1234.5,
0.0123,
999.9,
1000.0,
]
EXPECTED: dict[str, list[str]] = {
"~g": [
"12345.4",
"0",
"4725",
"8.06797e+7",
"1.23457e+9",
"1234.5",
"0.0123",
"999.9",
"1000",
], # noqa: E501
",d": [
"12,345",
"0",
"4,725",
"80,679,663",
"1,234,567,890",
"1,235",
"0",
"1,000",
"1,000",
], # noqa: E501
".1s": ["10k", "0", "5k", "80M", "1G", "1k", "10m", "1k", "1k"],
".3s": [
"12.3k",
"0.00",
"4.73k",
"80.7M",
"1.23G",
"1.23k",
"12.3m",
"1.00k",
"1.00k",
],
",.1%": [
"1,234,543.2%",
"0.0%",
"472,500.0%",
"8,067,966,300.0%",
"123,456,789,000.0%",
"123,450.0%",
"1.2%",
"99,990.0%",
"100,000.0%",
], # noqa: E501
".2%": [
"1234543.20%",
"0.00%",
"472500.00%",
"8067966300.00%",
"123456789000.00%",
"123450.00%",
"1.23%",
"99990.00%",
"100000.00%",
], # noqa: E501
".3%": [
"1234543.200%",
"0.000%",
"472500.000%",
"8067966300.000%",
"123456789000.000%",
"123450.000%",
"1.230%",
"99990.000%",
"100000.000%",
], # noqa: E501
".4r": [
"12350",
"0.000",
"4725",
"80680000",
"1235000000",
"1235",
"0.01230",
"999.9",
"1000",
], # noqa: E501
",.1f": [
"12,345.4",
"0.0",
"4,725.0",
"80,679,663.0",
"1,234,567,890.0",
"1,234.5",
"0.0",
"999.9",
"1,000.0",
], # noqa: E501
",.2f": [
"12,345.43",
"0.00",
"4,725.00",
"80,679,663.00",
"1,234,567,890.00",
"1,234.50",
"0.01",
"999.90",
"1,000.00",
], # noqa: E501
",.3f": [
"12,345.432",
"0.000",
"4,725.000",
"80,679,663.000",
"1,234,567,890.000",
"1,234.500",
"0.012",
"999.900",
"1,000.000",
], # noqa: E501
"+,": [
"+12,345.432",
"+0",
"+4,725",
"+80,679,663",
"+1,234,567,890",
"1,234.5",
"+0.0123",
"+999.9",
"+1,000",
], # noqa: E501
"$,.2f": [
"$12,345.43",
"$0.00",
"$4,725.00",
"$80,679,663.00",
"$1,234,567,890.00",
"$1,234.50",
"$0.01",
"$999.90",
"$1,000.00",
], # noqa: E501
"(,.2f": [
"12,345.43",
"0.00",
"4,725.00",
"80,679,663.00",
"1,234,567,890.00",
"(1,234.50)",
"0.01",
"999.90",
"1,000.00",
], # noqa: E501
"($,.2f": [
"$12,345.43",
"$0.00",
"$4,725.00",
"$80,679,663.00",
"$1,234,567,890.00",
"($1,234.50)",
"$0.01",
"$999.90",
"$1,000.00",
], # noqa: E501
" ,.2f": [
" 12,345.43",
" 0.00",
" 4,725.00",
" 80,679,663.00",
" 1,234,567,890.00",
"1,234.50",
" 0.01",
" 999.90",
" 1,000.00",
], # noqa: E501
}
@pytest.mark.parametrize("d3_format", list(EXPECTED))
def test_matches_frontend_d3_format(d3_format: str) -> None:
for value, expected in zip(VALUES, EXPECTED[d3_format], strict=True):
result = format_number_with_config(d3_format, None, value)
assert result == expected.replace("", "-"), (
f"{d3_format!r} of {value}: got {result!r}, expected {expected!r}"
)
@pytest.mark.parametrize(
"d3_format,value,expected",
[
(",", 4725.0, "4,725"),
(",", 1000.0, "1,000"),
(",", 12345.432, "12,345.432"),
(",", 4725.5, "4,725.5"),
(",", 0.00005, "0.00005"),
(",", -1234.5, "-1,234.5"),
("+,", 4725.0, "+4,725"),
("+,", 1000.0, "+1,000"),
("+,", -1234.5, "-1,234.5"),
],
)
def test_default_format_matches_d3_for_floats(
d3_format: str, value: float, expected: str
) -> None:
assert format_number_with_config(d3_format, None, value) == expected
@pytest.mark.parametrize(
"d3_format,value,expected",
[
(".2f", 0.125, "0.13"),
("$,.2f", 0.125, "$0.13"),
(".0f", 2.5, "3"),
(".1f", 0.25, "0.3"),
(".0%", 0.125, "13%"),
(".3s", 2.675, "2.67"),
(".4r", 0.12345, "0.1235"),
(".2f", 1.005, "1.00"),
(".1f", 0.35, "0.3"),
],
)
def test_rounding_matches_d3_binary_half_up(
d3_format: str, value: float, expected: str
) -> None:
assert format_number_with_config(d3_format, None, value) == expected
@pytest.mark.parametrize(
"d3_format,value,expected",
[
("~g", 0.00005, "0.00005"),
("~g", 0.000005, "0.000005"),
(".0s", 4725, "5k"),
(".0e", 2.5, "3e+0"),
(".2~e", 1000, "1e+3"),
(",.2f", 1e21, "1e+21"),
(",.1%", 1e20, "1e+22%"),
],
)
def test_additional_d3_parity_cases(
d3_format: str, value: float, expected: str
) -> None:
assert format_number_with_config(d3_format, None, value) == expected
@pytest.mark.parametrize(
"d3_format,value,expected",
[
("SMART_NUMBER", 12.345, "12.35"),
("SMART_NUMBER", 0.12345, "0.1235"),
("SMART_NUMBER", 0.00005, "50µ"),
("SMART_NUMBER", 5e-7, "500n"),
("SMART_NUMBER_SIGNED", 12.345, "+12.35"),
("n", 1234.5, "1,234.50"),
("x", 12, "12"),
],
)
def test_number_format_branch_coverage(
d3_format: str, value: float, expected: str
) -> None:
assert format_number_with_config(d3_format, None, value) == expected
def test_default_helper_and_whole_float_fallback() -> None:
assert format_default(1000, ",") == "1,000"
assert format_number_with_config(None, None, 42.0) == "42"
@pytest.mark.parametrize(
"preset",
[
"DURATION",
"DURATION_SUB",
"DURATION_COL",
"MEMORY_DECIMAL",
"MEMORY_BINARY",
"MEMORY_TRANSFER_RATE_DECIMAL",
"MEMORY_TRANSFER_RATE_BINARY",
],
)
def test_unported_frontend_presets_are_explicitly_rejected(preset: str) -> None:
with pytest.raises(ValueError, match="not available"):
format_numeric(preset, 66000)
assert format_number_with_config(preset, None, 66000) == "66000"
@pytest.mark.parametrize("d3_format", ["08,.2f", "*>12,.2f"])
def test_unsupported_d3_padding_is_explicitly_rejected(d3_format: str) -> None:
with pytest.raises(ValueError, match="padding"):
format_d3(d3_format, 1234.5)
assert format_number_with_config(d3_format, None, 1234.5) == "1234.5"