mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Co-authored-by: Jean Massucatto <massucattoj@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
563 lines
16 KiB
Python
563 lines
16 KiB
Python
# 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"
|