diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 818c50df..cbea9ef0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,19 @@ Unreleased documentation `changelog `_. +Added +^^^^^ + +* Added many unit test to supplement the feature existing feature tests. + [`#102 `_] +* Added much more input validation and corresponding messaging. + + * Checks on input types and values. + * Extra translations dictionaries are now checked so that keys must be + integers and values must consist of only English alphabetic + characters. + [`#157 `_] + Changed ^^^^^^^ @@ -21,16 +34,13 @@ Changed * Move modules containing public interfaces into an ``api`` sub-package. - * Break a bulky ``format_utils`` module into multiple modules, now in - a ``format_utils`` sub-package. + * Break the bulky ``format_utils`` module into multiple modules, now + in a ``format_utils`` sub-package. * Collect the main formatting algorithms into a ``formatting`` sub-package. * Sort tests into feature and unit tests. -* Extra translations dictionaries are now checked so that keys must be - integers and values must consist of only English alphabetic - characters. - [`#157 `_] +* Some utility code refactoring. ---- diff --git a/src/sciform/format_utils/__init__.py b/src/sciform/format_utils/__init__.py index a81a3026..7079fb9d 100644 --- a/src/sciform/format_utils/__init__.py +++ b/src/sciform/format_utils/__init__.py @@ -1,4 +1,5 @@ """Various formatting utilities.""" + from decimal import Decimal from typing import Union diff --git a/src/sciform/format_utils/exponents.py b/src/sciform/format_utils/exponents.py index db1a8755..e1df3f76 100644 --- a/src/sciform/format_utils/exponents.py +++ b/src/sciform/format_utils/exponents.py @@ -20,6 +20,8 @@ if TYPE_CHECKING: # pragma: no cover from decimal import Decimal + from sciform.options.option_types import AutoExpVal + def get_translation_dict( exp_format: ExpFormatEnum, @@ -118,7 +120,7 @@ def get_val_unc_exp( val: Decimal, unc: Decimal, exp_mode: ExpModeEnum, - input_exp: int, + input_exp: int | AutoExpVal, ) -> int: """Get exponent for value/uncertainty formatting.""" if val.is_finite() and unc.is_finite(): diff --git a/src/sciform/format_utils/make_strings.py b/src/sciform/format_utils/make_strings.py index 80063770..7ef69ad0 100644 --- a/src/sciform/format_utils/make_strings.py +++ b/src/sciform/format_utils/make_strings.py @@ -2,16 +2,14 @@ from __future__ import annotations +import decimal from typing import TYPE_CHECKING -from sciform.format_utils.exponents import get_exp_str from sciform.format_utils.numbers import ( - get_top_digit, + get_top_dec_place, ) from sciform.options.option_types import ( DecimalSeparatorEnums, - ExpFormatEnum, - ExpModeEnum, SeparatorEnum, SignModeEnum, ) @@ -22,56 +20,79 @@ def get_sign_str(num: Decimal, sign_mode: SignModeEnum) -> str: """Get the format sign string.""" - if num < 0: - # Always return "-" for negative numbers. - sign_str = "-" - elif num > 0: - # Return "+", " ", or "" for positive numbers. - if sign_mode is SignModeEnum.ALWAYS: - sign_str = "+" - elif sign_mode is SignModeEnum.SPACE: + with decimal.localcontext() as ctx: + # TODO: Consider wrapping all the formatting in this context. + ctx.traps[decimal.InvalidOperation] = False + + if num < 0: + # Always return "-" for negative numbers. + sign_str = "-" + elif num > 0: + # Return "+", " ", or "" for positive numbers. + if sign_mode is SignModeEnum.ALWAYS: + sign_str = "+" + elif sign_mode is SignModeEnum.SPACE: + sign_str = " " + elif sign_mode is SignModeEnum.NEGATIVE: + sign_str = "" + else: + msg = f"Invalid sign mode {sign_mode}." + raise ValueError(msg) + elif sign_mode is SignModeEnum.ALWAYS or sign_mode is SignModeEnum.SPACE: + """ + For anything else (typically 0, possibly nan) return " " in "+" and " " + modes. + """ sign_str = " " - elif sign_mode is SignModeEnum.NEGATIVE: - sign_str = "" else: - msg = f"Invalid sign mode {sign_mode}." - raise ValueError(msg) - elif sign_mode is SignModeEnum.ALWAYS or sign_mode is SignModeEnum.SPACE: - # For anything else (typically 0, possibly nan) return " " in "+" and " " modes - sign_str = " " - else: - # Otherwise return the empty string. - sign_str = "" + # Otherwise return the empty string. + sign_str = "" return sign_str -def get_pad_str(left_pad_char: str, top_digit: int, top_padded_digit: int) -> str: - """Get the string padding from top_digit place to top_padded_digit place.""" - if top_padded_digit > top_digit: - pad_len = top_padded_digit - max(top_digit, 0) +def get_pad_str( + left_pad_char: str, + top_dec_place: int, + top_padded_dec_place: int, +) -> str: + """Get the string padding from top_dec_place place to top_padded_dec_place place.""" + if top_padded_dec_place > top_dec_place: + pad_len = top_padded_dec_place - max(top_dec_place, 0) pad_str = left_pad_char * pad_len else: pad_str = "" return pad_str -def format_num_by_top_bottom_dig( +def get_abs_num_str_by_bottom_dec_place( num: Decimal, - target_top_digit: int, - target_bottom_digit: int, + target_bottom_dec_place: int, +) -> str: + """Format a number according to specified bottom decimal places.""" + prec = max(0, -target_bottom_dec_place) + abs_mantissa_str = f"{abs(num):.{prec}f}" + return abs_mantissa_str + + +def construct_num_str( + num: Decimal, + target_top_dec_place: int, + target_bottom_dec_place: int, sign_mode: SignModeEnum, left_pad_char: str, ) -> str: - """Format a number according to specified top and bottom digit places.""" - print_prec = max(0, -target_bottom_digit) - abs_mantissa_str = f"{abs(num):.{print_prec}f}" + """Format a number to a specified decimal place, with left padding and a sign symbol.""" # noqa: E501 + abs_num_str = get_abs_num_str_by_bottom_dec_place( + num, + target_bottom_dec_place, + ) sign_str = get_sign_str(num, sign_mode) - num_top_digit = get_top_digit(num) - pad_str = get_pad_str(left_pad_char, num_top_digit, target_top_digit) - return f"{sign_str}{pad_str}{abs_mantissa_str}" + num_top_dec_place = get_top_dec_place(num) + pad_str = get_pad_str(left_pad_char, num_top_dec_place, target_top_dec_place) + return f"{sign_str}{pad_str}{abs_num_str}" def construct_val_unc_str( # noqa: PLR0913 @@ -112,31 +133,13 @@ def construct_val_unc_str( # noqa: PLR0913 return val_unc_str -def construct_val_unc_exp_str( # noqa: PLR0913 +def construct_val_unc_exp_str( *, val_unc_str: str, - exp_val: int, - exp_mode: ExpModeEnum, - exp_format: ExpFormatEnum, - extra_si_prefixes: dict[int, str | None], - extra_iec_prefixes: dict[int, str | None], - extra_parts_per_forms: dict[int, str | None], - capitalize: bool, - superscript: bool, + exp_str: str, paren_uncertainty: bool, ) -> str: """Combine the val_unc_str into the final val_unc_exp_str.""" - exp_str = get_exp_str( - exp_val=exp_val, - exp_mode=exp_mode, - exp_format=exp_format, - capitalize=capitalize, - superscript=superscript, - extra_si_prefixes=extra_si_prefixes, - extra_iec_prefixes=extra_iec_prefixes, - extra_parts_per_forms=extra_parts_per_forms, - ) - if exp_str == "": val_unc_exp_str = val_unc_str elif paren_uncertainty: diff --git a/src/sciform/format_utils/numbers.py b/src/sciform/format_utils/numbers.py index e30bed71..811394bf 100644 --- a/src/sciform/format_utils/numbers.py +++ b/src/sciform/format_utils/numbers.py @@ -7,7 +7,11 @@ from math import floor, log2 from typing import Literal -from sciform.formatting.parser import any_val_pattern +from sciform.formatting.parser import ( + ascii_exp_pattern, + finite_val_pattern, + non_finite_val_pattern, +) from sciform.options.option_types import ( AutoDigits, AutoExpVal, @@ -15,48 +19,49 @@ ) -def get_top_digit(num: Decimal) -> int: +def get_top_dec_place(num: Decimal) -> int: """Get the decimal place of a decimal's most significant digit.""" if not num.is_finite() or num == 0: return 0 - _, digits, exp = num.as_tuple() + _, digits, exp = num.normalize().as_tuple() return len(digits) + exp - 1 -def get_top_digit_binary(num: Decimal) -> int: +def get_top_dec_place_binary(num: Decimal) -> int: """Get the decimal place of a decimal's most significant digit.""" if not num.is_finite() or num == 0: return 0 return floor(log2(abs(num))) -def get_bottom_digit(num: Decimal) -> int: +def get_bottom_dec_place(num: Decimal) -> int: """Get the decimal place of a decimal's least significant digit.""" if not num.is_finite(): return 0 - _, _, exp = num.as_tuple() + _, _, exp = num.normalize().as_tuple() return exp -def get_val_unc_top_digit( +def get_val_unc_top_dec_place( val_mantissa: Decimal, unc_mantissa: Decimal, - input_top_digit: int | AutoDigits, + input_top_dec_place: int, *, left_pad_matching: bool, ) -> int | AutoDigits: - """Get top digit place for value/uncertainty formatting.""" + """Get top decimal place for value/uncertainty formatting.""" if left_pad_matching: - val_top_digit = get_top_digit(val_mantissa) - unc_top_digit = get_top_digit(unc_mantissa) - new_top_digit = max( - input_top_digit, - val_top_digit, - unc_top_digit, + val_top_dec_place = get_top_dec_place(val_mantissa) + unc_top_dec_place = get_top_dec_place(unc_mantissa) + new_top_dec_place = max( + input_top_dec_place, + val_top_dec_place, + unc_top_dec_place, ) else: - new_top_digit = input_top_digit - return new_top_digit + new_top_dec_place = input_top_dec_place + new_top_dec_place = max(0, new_top_dec_place) + return new_top_dec_place def get_fixed_exp( @@ -74,7 +79,7 @@ def get_scientific_exp( input_exp: int | type(AutoExpVal), ) -> int: """Get the exponent for scientific formatting mode.""" - return get_top_digit(num) if input_exp is AutoExpVal else input_exp + return get_top_dec_place(num) if input_exp is AutoExpVal else input_exp def get_engineering_exp( @@ -85,7 +90,7 @@ def get_engineering_exp( ) -> int: """Get the exponent for engineering formatting modes.""" if input_exp is AutoExpVal: - exp_val = get_top_digit(num) + exp_val = get_top_dec_place(num) exp_val = exp_val // 3 * 3 if not shifted else (exp_val + 1) // 3 * 3 else: if input_exp % 3 != 0: @@ -106,7 +111,7 @@ def get_binary_exp( ) -> int: """Get the exponent for binary formatting modes.""" if input_exp is AutoExpVal: - exp_val = get_top_digit_binary(num) + exp_val = get_top_dec_place_binary(num) if iec: exp_val = (exp_val // 10) * 10 else: @@ -155,21 +160,28 @@ def get_mantissa_exp_base( return mantissa, exp, base -# Optional parentheses needed to handle (nan)e+00 case -mantissa_exp_pattern = re.compile( - rf""" - ^ - \(?(?P{any_val_pattern})\)? - (?P[eEbB].*?)? - $ -""", - re.VERBOSE, -) +# language=pythonverboseregexp noqa: ERA001 +no_exp_pattern = rf"^(?P{non_finite_val_pattern})$" +# language=pythonverboseregexp noqa: ERA001 +optional_exp_pattern = rf""" +^(?P{finite_val_pattern})(?P{ascii_exp_pattern})?$ +""" +# language=pythonverboseregexp noqa: ERA001 +always_exp_pattern = rf""" +^ +\((?P{non_finite_val_pattern})\) +(?P{ascii_exp_pattern}) +$ +""" def parse_mantissa_from_ascii_exp_str(number_str: str) -> str: """Break val/unc mantissa/exp strings into mantissa strings and an exp string.""" - if match := mantissa_exp_pattern.match(number_str): - return match.group("mantissa_str") + if match := re.match(no_exp_pattern, number_str, re.VERBOSE): + return match.group("mantissa") + if match := re.match(optional_exp_pattern, number_str, re.VERBOSE): + return match.group("mantissa") + if match := re.match(always_exp_pattern, number_str, re.VERBOSE): + return match.group("mantissa") msg = f'Invalid number string "{number_str}".' raise ValueError(msg) diff --git a/src/sciform/format_utils/rounding.py b/src/sciform/format_utils/rounding.py index af530bae..5cd6102e 100644 --- a/src/sciform/format_utils/rounding.py +++ b/src/sciform/format_utils/rounding.py @@ -5,8 +5,8 @@ from decimal import Decimal from sciform.format_utils.numbers import ( - get_bottom_digit, - get_top_digit, + get_bottom_dec_place, + get_top_dec_place, ) from sciform.options.option_types import ( AutoDigits, @@ -16,26 +16,30 @@ def get_pdg_round_digit(num: Decimal) -> int: """ - Determine the PDG rounding digit place to which to round. + Determine the PDG rounding decimal place to which to round. - Calculate the appropriate digit place to round to according to the - particle data group 3-5-4 rounding rules. + Calculate the appropriate decimal place to which to round according + to the particle data group 3-5-4 rounding rules. See https://pdg.lbl.gov/2010/reviews/rpp2010-rev-rpp-intro.pdf Section 5.2 """ - top_digit = get_top_digit(num) + if not num.is_finite(): + msg = f"num must be finite, not {num}." + raise ValueError(msg) + + top_dec_place = get_top_dec_place(num) # Bring num to be between 100 and 1000. - num_top_three_digs = num * Decimal(10) ** (Decimal(2) - Decimal(top_digit)) + num_top_three_digs = num * Decimal(10) ** (Decimal(2) - Decimal(top_dec_place)) num_top_three_digs.quantize(1) - new_top_digit = get_top_digit(num_top_three_digs) - num_top_three_digs = num_top_three_digs * 10 ** (2 - new_top_digit) + new_top_dec_place = get_top_dec_place(num_top_three_digs) + num_top_three_digs = num_top_three_digs * 10 ** (2 - new_top_dec_place) if 100 <= num_top_three_digs <= 354: - round_digit = top_digit - 1 + round_digit = top_dec_place - 1 elif 355 <= num_top_three_digs <= 949: - round_digit = top_digit + round_digit = top_dec_place elif 950 <= num_top_three_digs <= 999: """ Here we set the round digit equal to the top digit. But since @@ -44,30 +48,32 @@ def get_pdg_round_digit(num: Decimal) -> int: correspond to displaying two digits of uncertainty: "10". e.g. 123.45632 +/- 0.987 would be rounded as 123.5 +/- 1.0. """ - round_digit = top_digit + round_digit = top_dec_place else: # pragma: no cover - raise ValueError + msg = f"Unable to determine PDG rounding decimal place for {num}" + raise ValueError(msg) return round_digit -def get_round_digit( +def get_round_dec_place( num: Decimal, round_mode: RoundModeEnum, ndigits: int | type(AutoDigits), *, pdg_sig_figs: bool = False, ) -> int: - """Get the digit place to which to round.""" + """Get the decimal place to which to round.""" + # TODO: Handle nan and inf if round_mode is RoundModeEnum.SIG_FIG: if pdg_sig_figs: round_digit = get_pdg_round_digit(num) elif ndigits is AutoDigits: - round_digit = get_bottom_digit(num) + round_digit = get_bottom_dec_place(num) else: - round_digit = get_top_digit(num) - (ndigits - 1) + round_digit = get_top_dec_place(num) - (ndigits - 1) elif round_mode is RoundModeEnum.DEC_PLACE: - round_digit = get_bottom_digit(num) if ndigits is AutoDigits else -ndigits + round_digit = get_bottom_dec_place(num) if ndigits is AutoDigits else -ndigits else: msg = f"Unhandled round mode: {round_mode}." raise ValueError(msg) @@ -83,23 +89,29 @@ def round_val_unc( ) -> tuple[Decimal, Decimal, int]: """Simultaneously round the value and uncertainty.""" if unc.is_finite() and unc != 0: - round_digit = get_round_digit( + round_digit = get_round_dec_place( unc, RoundModeEnum.SIG_FIG, ndigits, pdg_sig_figs=use_pdg_sig_figs, ) unc_rounded = round(unc, -round_digit) - else: - round_digit = get_round_digit( + if val.is_finite(): + val_rounded = round(val, -round_digit) + else: + val_rounded = val + elif val.is_finite(): + round_digit = get_round_dec_place( val, RoundModeEnum.SIG_FIG, ndigits, pdg_sig_figs=False, ) unc_rounded = unc - if val.is_finite(): val_rounded = round(val, -round_digit) else: + round_digit = 0 + unc_rounded = unc val_rounded = val + return val_rounded, unc_rounded, round_digit diff --git a/src/sciform/formatting/number_formatting.py b/src/sciform/formatting/number_formatting.py index e15cdddc..2b2d865c 100644 --- a/src/sciform/formatting/number_formatting.py +++ b/src/sciform/formatting/number_formatting.py @@ -1,4 +1,5 @@ """Main formatting functions.""" + from __future__ import annotations from dataclasses import replace @@ -7,21 +8,20 @@ from warnings import warn from sciform.api.formatted_number import FormattedNumber -from sciform.format_utils.exponents import get_val_unc_exp +from sciform.format_utils.exponents import get_exp_str, get_val_unc_exp from sciform.format_utils.grouping import add_separators from sciform.format_utils.make_strings import ( + construct_num_str, construct_val_unc_exp_str, construct_val_unc_str, - format_num_by_top_bottom_dig, - get_exp_str, get_sign_str, ) from sciform.format_utils.numbers import ( get_mantissa_exp_base, - get_val_unc_top_digit, + get_val_unc_top_dec_place, parse_mantissa_from_ascii_exp_str, ) -from sciform.format_utils.rounding import get_round_digit, round_val_unc +from sciform.format_utils.rounding import get_round_dec_place, round_val_unc from sciform.formatting.parser import parse_val_unc_from_input from sciform.options.conversion import finalize_populated_options, populate_options from sciform.options.option_types import ( @@ -122,7 +122,7 @@ def format_num(num: Decimal, options: FinalizedOptions) -> str: exp_mode = options.exp_mode ndigits = options.ndigits mantissa, temp_exp_val, base = get_mantissa_exp_base(num, exp_mode, exp_val) - round_digit = get_round_digit(mantissa, round_mode, ndigits) + round_digit = get_round_dec_place(mantissa, round_mode, ndigits) mantissa_rounded = round(mantissa, -round_digit) """ @@ -131,7 +131,7 @@ def format_num(num: Decimal, options: FinalizedOptions) -> str: """ rounded_num = mantissa_rounded * Decimal(base) ** Decimal(temp_exp_val) mantissa, exp_val, base = get_mantissa_exp_base(rounded_num, exp_mode, exp_val) - round_digit = get_round_digit(mantissa, round_mode, ndigits) + round_digit = get_round_dec_place(mantissa, round_mode, ndigits) mantissa_rounded = round(mantissa, -int(round_digit)) mantissa_rounded = cast(Decimal, mantissa_rounded) @@ -145,7 +145,7 @@ def format_num(num: Decimal, options: FinalizedOptions) -> str: exp_val = 0 left_pad_char = options.left_pad_char.value - mantissa_str = format_num_by_top_bottom_dig( + mantissa_str = construct_num_str( mantissa_rounded.normalize(), options.left_pad_dec_place, round_digit, @@ -247,7 +247,7 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str input_exp=exp_val, ) - new_top_digit = get_val_unc_top_digit( + new_top_dec_place = get_val_unc_top_dec_place( val_mantissa, unc_mantissa, options.left_pad_dec_place, @@ -271,7 +271,7 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str """ val_format_options = replace( options, - left_pad_dec_place=new_top_digit, + left_pad_dec_place=new_top_dec_place, round_mode=RoundModeEnum.DEC_PLACE, ndigits=ndigits, exp_mode=exp_mode, @@ -303,8 +303,7 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str ) if val.is_finite() or unc.is_finite() or options.nan_inf_exp: - val_unc_exp_str = construct_val_unc_exp_str( - val_unc_str=val_unc_str, + exp_str = get_exp_str( exp_val=exp_val, exp_mode=exp_mode, exp_format=options.exp_format, @@ -313,6 +312,10 @@ def format_val_unc(val: Decimal, unc: Decimal, options: FinalizedOptions) -> str extra_parts_per_forms=options.extra_parts_per_forms, capitalize=options.capitalize, superscript=options.superscript, + ) + val_unc_exp_str = construct_val_unc_exp_str( + val_unc_str=val_unc_str, + exp_str=exp_str, paren_uncertainty=options.paren_uncertainty, ) else: diff --git a/src/sciform/formatting/output_conversion.py b/src/sciform/formatting/output_conversion.py index e875edd3..a7ee1420 100644 --- a/src/sciform/formatting/output_conversion.py +++ b/src/sciform/formatting/output_conversion.py @@ -1,4 +1,5 @@ """Convert sciform outputs into latex commands.""" + from __future__ import annotations import re diff --git a/src/sciform/formatting/parser.py b/src/sciform/formatting/parser.py index dfd28b08..a6448241 100644 --- a/src/sciform/formatting/parser.py +++ b/src/sciform/formatting/parser.py @@ -409,7 +409,7 @@ def parse_val_unc_from_input( However, users may also pass in strings with more complicated structure than those strings which can be natively converted to float or Decimal. For example - >>> from sciform.parser import parse_val_unc_from_input + >>> from sciform.formatting.parser import parse_val_unc_from_input >>> val, unc = parse_val_unc_from_input("123 +/- 4", None) >>> print(val) 123 @@ -425,9 +425,9 @@ def parse_val_unc_from_input( >>> val, unc = parse_val_unc_from_input("123(4) k", None) >>> print(val) - 123000 + 1.23E+5 >>> print(unc) - 4000 + 4E+3 """ if isinstance(value, (float, int)): value = Decimal(str(value)) diff --git a/src/sciform/options/conversion.py b/src/sciform/options/conversion.py index f578c5bc..d5ac9ad7 100644 --- a/src/sciform/options/conversion.py +++ b/src/sciform/options/conversion.py @@ -8,7 +8,6 @@ from sciform.options import global_options, option_types from sciform.options.finalized_options import FinalizedOptions from sciform.options.populated_options import PopulatedOptions -from sciform.options.validation import validate_options if TYPE_CHECKING: # pragma: no cover from sciform.options.input_options import InputOptions @@ -86,7 +85,6 @@ def populate_options(input_options: InputOptions) -> PopulatedOptions: kwargs[key] = populated_value populated_options = PopulatedOptions(**kwargs) - validate_options(populated_options) return populated_options diff --git a/src/sciform/options/finalized_options.py b/src/sciform/options/finalized_options.py index 1289ec4b..23ac95f6 100644 --- a/src/sciform/options/finalized_options.py +++ b/src/sciform/options/finalized_options.py @@ -44,4 +44,4 @@ class FinalizedOptions: pm_whitespace: bool def __post_init__(self: FinalizedOptions) -> None: - validate_options(self) + validate_options(self, none_allowed=False) diff --git a/src/sciform/options/input_options.py b/src/sciform/options/input_options.py index 898f631b..8f33b667 100644 --- a/src/sciform/options/input_options.py +++ b/src/sciform/options/input_options.py @@ -1,6 +1,5 @@ """InputOptions Dataclass which stores user input.""" - from __future__ import annotations from dataclasses import asdict, dataclass @@ -79,7 +78,7 @@ class InputOptions: add_ppth_form: bool = None def __post_init__(self: InputOptions) -> None: - validate_options(self) + validate_options(self, none_allowed=True) def as_dict(self: InputOptions) -> dict[str, Any]: """ diff --git a/src/sciform/options/populated_options.py b/src/sciform/options/populated_options.py index 6e5ddd52..7aeb1781 100644 --- a/src/sciform/options/populated_options.py +++ b/src/sciform/options/populated_options.py @@ -1,6 +1,5 @@ """InputOptions Dataclass which stores user input.""" - from __future__ import annotations from dataclasses import asdict, dataclass @@ -117,7 +116,7 @@ class PopulatedOptions: pm_whitespace: bool def __post_init__(self: PopulatedOptions) -> None: - validate_options(self) + validate_options(self, none_allowed=False) def as_dict(self: PopulatedOptions) -> dict[str, Any]: """ diff --git a/src/sciform/options/validation.py b/src/sciform/options/validation.py index c2c87ff0..2702e470 100644 --- a/src/sciform/options/validation.py +++ b/src/sciform/options/validation.py @@ -15,20 +15,44 @@ def validate_options( options: InputOptions | PopulatedOptions | FinalizedOptions, + *, + none_allowed: bool, ) -> None: """Validate user inputs.""" - validate_sig_fig_round_mode(options) - validate_exp_val(options) - validate_separator_options(options) - validate_extra_translations(options) + validate_rounding(options, none_allowed=none_allowed) + validate_exp_options(options, none_allowed=none_allowed) + validate_sign_mode(options, none_allowed=none_allowed) + validate_separator_options(options, none_allowed=none_allowed) + validate_extra_translations(options, none_allowed=none_allowed) + validate_left_pad_options(options, none_allowed=none_allowed) -def validate_sig_fig_round_mode( +allowed_round_modes = get_args(option_types.RoundMode) + + +def validate_rounding( options: InputOptions | PopulatedOptions | FinalizedOptions, + *, + none_allowed: bool, ) -> None: r"""Validate ndigits if round_mode == "sig_fig".""" if ( - options.round_mode == "sig_fig" + not (none_allowed and options.round_mode is None) + and options.round_mode not in allowed_round_modes + ): + msg = f"round_mode must be in {allowed_round_modes}, not {options.round_mode}." + raise ValueError(msg) + + if not (none_allowed and options.ndigits is None) and not isinstance( + options.ndigits, + (int, type(option_types.AutoDigits)), + ): + msg = f'ndigits must be an "int" or "AutoDigits", not {options.ndigits}.' + raise TypeError(msg) + + if ( + not (none_allowed and options.round_mode is None) + and options.round_mode == "sig_fig" and isinstance(options.ndigits, int) and options.ndigits < 1 ): @@ -36,39 +60,87 @@ def validate_sig_fig_round_mode( raise ValueError(msg) -def validate_exp_val( +allowed_exp_modes = get_args(option_types.ExpMode) +allowed_exp_val_types = (int, type(option_types.AutoExpVal), type(None)) +allowed_exp_formats = get_args(option_types.ExpFormat) + + +def validate_exp_options( options: InputOptions | PopulatedOptions | FinalizedOptions, + *, + none_allowed: bool, ) -> None: - """Validate exp_val.""" - if options.exp_val is not option_types.AutoExpVal and options.exp_val is not None: - if options.exp_mode in ["fixed_point", "percent"] and options.exp_val != 0: - msg = ( - f"Exponent must must be 0, not exp_val={options.exp_val}, for " - f"fixed point and percent exponent modes." - ) - raise ValueError(msg) - if ( - options.exp_mode in ["engineering", "engineering_shifted"] - and options.exp_val % 3 != 0 - ): - msg = ( - f"Exponent must be a multiple of 3, not exp_val={options.exp_val}, " - f"for engineering exponent modes." - ) - raise ValueError(msg) - if options.exp_mode == "binary_iec" and options.exp_val % 10 != 0: - msg = ( - f"Exponent must be a multiple of 10, not " - f"exp_val={options.exp_val}, for binary IEC exponent mode." - ) - raise ValueError(msg) + """Validate exponent options.""" + if ( + not (none_allowed and options.exp_mode is None) + and options.exp_mode not in allowed_exp_modes + ): + msg = f"exp_mode must be in {allowed_exp_modes}, not {options.exp_mode}." + raise ValueError(msg) + + if not (none_allowed and options.exp_val is None): + if not isinstance(options.exp_val, allowed_exp_val_types): + msg = f"exp_val must be an int, AutoExpVal, or None, not {options.exp_val}." + raise TypeError(msg) + + if options.exp_val is not option_types.AutoExpVal: + if options.exp_mode in ["fixed_point", "percent"] and options.exp_val != 0: + msg = ( + f"Exponent must must be 0, not exp_val={options.exp_val}, for " + f"fixed point and percent exponent modes." + ) + raise ValueError(msg) + if ( + options.exp_mode in ["engineering", "engineering_shifted"] + and options.exp_val % 3 != 0 + ): + msg = ( + f"Exponent must be a multiple of 3, not exp_val={options.exp_val}, " + f"for engineering exponent modes." + ) + raise ValueError(msg) + if options.exp_mode == "binary_iec" and options.exp_val % 10 != 0: + msg = ( + f"Exponent must be a multiple of 10, not " + f"exp_val={options.exp_val}, for binary IEC exponent mode." + ) + raise ValueError(msg) + + if ( + not (none_allowed and options.exp_format is None) + and options.exp_format not in allowed_exp_formats + ): + msg = ( + f"{options.exp_format} must be in {allowed_exp_formats}, not " + f"{options.exp_format}." + ) + raise ValueError(msg) + + +allowed_sign_modes = get_args(option_types.SignMode) + + +def validate_sign_mode( + options: InputOptions | PopulatedOptions | FinalizedOptions, + *, + none_allowed: bool, +) -> None: + r"""Validate ndigits if round_mode == "sig_fig".""" + if ( + not (none_allowed and options.sign_mode is None) + and options.sign_mode not in allowed_sign_modes + ): + msg = f"sign_mode must be in {allowed_sign_modes}, not {options.sign_mode}." + raise ValueError(msg) def validate_separator_options( options: InputOptions | PopulatedOptions | FinalizedOptions, + *, + none_allowed: bool, ) -> None: """Validate separator user input.""" - if options.upper_separator is not None: + if not (none_allowed and options.upper_separator is None): if options.upper_separator not in get_args(option_types.UpperSeparators): msg = ( f"upper_separator must be in " @@ -83,7 +155,7 @@ def validate_separator_options( ) raise ValueError(msg) - if options.decimal_separator is not None and ( + if not (none_allowed and options.decimal_separator is None) and ( options.decimal_separator not in get_args(option_types.DecimalSeparators) ): msg = ( @@ -93,7 +165,7 @@ def validate_separator_options( ) raise ValueError(msg) - if options.lower_separator is not None and ( + if not (none_allowed and options.lower_separator is None) and ( options.lower_separator not in get_args(option_types.LowerSeparators) ): msg = ( @@ -105,6 +177,8 @@ def validate_separator_options( def validate_extra_translations( options: InputOptions | PopulatedOptions | FinalizedOptions, + *, + none_allowed: bool, ) -> None: """Validate translation dictionary have int keys and alphabetic values.""" translations_dicts = [ @@ -114,7 +188,7 @@ def validate_extra_translations( ] for translation_dict in translations_dicts: - if translation_dict is not None: + if not (none_allowed and translation_dict is None): for key, value in translation_dict.items(): if not isinstance(key, int): msg = f'Translation dictionary keys must be integers, not "{key}".' @@ -126,3 +200,24 @@ def validate_extra_translations( f'"{value}".' ) raise ValueError(msg) + + +def validate_left_pad_options( + options: InputOptions | PopulatedOptions | FinalizedOptions, + *, + none_allowed: bool, +) -> None: + """Validate left padding options.""" + dec_place_msg = ( + f"left_pad_dec_place must an an int >= 0, not {options.left_pad_dec_place}." + ) + if not (none_allowed and options.left_pad_dec_place is None): + if not isinstance(options.left_pad_dec_place, int): + raise TypeError(dec_place_msg) + if options.left_pad_dec_place < 0: + raise ValueError(dec_place_msg) + if not ( + none_allowed and options.left_pad_char is None + ) and options.left_pad_char not in [0, "0", " "]: + msg = f'left_pad_char must be in [" ", "0", 0], not {options.left_pad_char}.' + raise ValueError(msg) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29b..59e458af 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1,10 @@ +import unittest +from math import isnan + + +class NanTestCase(unittest.TestCase): + def assertNanEqual(self, first, second, msg=None): # noqa: N802 + if isnan(first): + self.assertTrue(isnan(second), msg=msg) + else: + self.assertEqual(first, second, msg=msg) diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index e58da24f..999bd2fc 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -1,7 +1,7 @@ import doctest from sciform.api import formatter, scinum -from sciform.formatting import output_conversion +from sciform.formatting import output_conversion, parser from sciform.options import input_options, populated_options @@ -9,6 +9,7 @@ def load_tests(loader, tests, ignore): # noqa: ARG001 tests.addTests(doctest.DocTestSuite(formatter)) tests.addTests(doctest.DocTestSuite(scinum)) tests.addTests(doctest.DocTestSuite(output_conversion)) + tests.addTests(doctest.DocTestSuite(parser)) tests.addTests(doctest.DocTestSuite(input_options)) tests.addTests(doctest.DocTestSuite(populated_options)) return tests diff --git a/tests/unit/format_utils/__init__.py b/tests/unit/format_utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/format_utils/test_exp_translations.py b/tests/unit/format_utils/test_exp_translations.py new file mode 100644 index 00000000..b7343984 --- /dev/null +++ b/tests/unit/format_utils/test_exp_translations.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import re +import unittest +from typing import Any + +from sciform.format_utils import exp_translations + +alphabetic_pattern = re.compile(r"[a-zA-Zμ]?") + + +class TestExpTranslations(unittest.TestCase): + def validate_translations_dict(self, translation_dict: dict[str, Any]) -> None: + for key, value in translation_dict.items(): + with self.subTest(key=key, value=value): + self.assertIsInstance(key, int) + self.assertIsNotNone(re.match(alphabetic_pattern, value)) + + def test_si_prefixes(self): + self.validate_translations_dict(exp_translations.val_to_si_dict) + + def test_iec_prefixes(self): + self.validate_translations_dict(exp_translations.val_to_iec_dict) + + def test__prefixes(self): + self.validate_translations_dict(exp_translations.val_to_parts_per_dict) diff --git a/tests/unit/format_utils/test_exponent_utils.py b/tests/unit/format_utils/test_exponent_utils.py new file mode 100644 index 00000000..20cf404a --- /dev/null +++ b/tests/unit/format_utils/test_exponent_utils.py @@ -0,0 +1,319 @@ +from __future__ import annotations + +import unittest +from decimal import Decimal +from typing import Any, Literal, Union + +from sciform.format_utils import exp_translations, exponents +from sciform.options.option_types import AutoExpVal, ExpFormatEnum, ExpModeEnum + +Base = Literal[10, 2] +GetTranslationDictCase = tuple[ + tuple[ + ExpFormatEnum, + Base, + dict[int, str], + dict[int, str], + dict[int, str], + ], + dict[int, str], +] +GetStandardExpStrCase = tuple[tuple[int, int, bool], str] +GetValUncExpCase = tuple[ + tuple[Decimal, Decimal, ExpModeEnum, Union[int, type[AutoExpVal]]], + int, +] + + +class TestExponentUtils(unittest.TestCase): + def test_get_translation_dict_si(self): + translation_dict = exponents.get_translation_dict( + ExpFormatEnum.PREFIX, + 10, + extra_si_prefixes={-2: "c", -1: "d", +3: "km", -3: None}, + extra_iec_prefixes={}, + extra_parts_per_forms={}, + ) + for key, value in exp_translations.val_to_si_dict.items(): + self.assertIn(key, translation_dict) + if key not in (-3, +3): + self.assertEqual(value, translation_dict[key]) + self.assertIn(-2, translation_dict) + self.assertEqual("c", translation_dict[-2]) + self.assertIn(-1, translation_dict) + self.assertEqual("d", translation_dict[-1]) + self.assertEqual("km", translation_dict[3]) + self.assertEqual(None, translation_dict[-3]) + + def test_get_translation_dict_parts_per(self): + translation_dict = exponents.get_translation_dict( + ExpFormatEnum.PARTS_PER, + 10, + extra_si_prefixes={}, + extra_iec_prefixes={}, + extra_parts_per_forms={-3: "ppth", -4: "pptt", -9: None, -12: "ppb"}, + ) + for key, value in exp_translations.val_to_parts_per_dict.items(): + self.assertIn(key, translation_dict) + if key not in (-9, -12): + self.assertEqual(value, translation_dict[key]) + self.assertEqual("ppth", translation_dict[-3]) + self.assertIn(-4, translation_dict) + self.assertEqual("pptt", translation_dict[-4]) + self.assertEqual(None, translation_dict[-9]) + self.assertEqual("ppb", translation_dict[-12]) + + def test_get_translation_dict_iec(self): + translation_dict = exponents.get_translation_dict( + ExpFormatEnum.PREFIX, + 2, + extra_si_prefixes={}, + extra_iec_prefixes={10: "Kb", 20: "MiB", 30: None}, + extra_parts_per_forms={}, + ) + for key, value in exp_translations.val_to_iec_dict.items(): + self.assertIn(key, translation_dict) + if key not in (10, 20, 30): + self.assertEqual(value, translation_dict[key]) + self.assertEqual("Kb", translation_dict[10]) + self.assertEqual("MiB", translation_dict[20]) + self.assertEqual(None, translation_dict[30]) + + def test_get_translation_dict_invalid_base(self): + self.assertRaises( + ValueError, + exponents.get_translation_dict, + ExpFormatEnum.PREFIX, + 8, + {}, + {}, + {}, + ) + + def test_get_translation_dict_invalid_format(self): + self.assertRaises( + ValueError, + exponents.get_translation_dict, + "prefix", + 10, + {}, + {}, + {}, + ) + + def test_get_standard_exp_str(self): + cases: list[GetStandardExpStrCase] = [ + ((10, -111, False), "e-111"), + ((10, -15, False), "e-15"), + ((10, -2, False), "e-02"), + ((10, -1, False), "e-01"), + ((10, 0, False), "e+00"), + ((10, +1, False), "e+01"), + ((10, +2, False), "e+02"), + ((10, +15, False), "e+15"), + ((10, +111, False), "e+111"), + ((10, -111, True), "E-111"), + ((10, -15, True), "E-15"), + ((10, -2, True), "E-02"), + ((10, -1, True), "E-01"), + ((10, 0, True), "E+00"), + ((10, +1, True), "E+01"), + ((10, +2, True), "E+02"), + ((10, +15, True), "E+15"), + ((10, +111, True), "E+111"), + ((2, -111, False), "b-111"), + ((2, -15, False), "b-15"), + ((2, -2, False), "b-02"), + ((2, -1, False), "b-01"), + ((2, 0, False), "b+00"), + ((2, +1, False), "b+01"), + ((2, +2, False), "b+02"), + ((2, +15, False), "b+15"), + ((2, +111, False), "b+111"), + ((2, -111, True), "B-111"), + ((2, -15, True), "B-15"), + ((2, -2, True), "B-02"), + ((2, -1, True), "B-01"), + ((2, 0, True), "B+00"), + ((2, +1, True), "B+01"), + ((2, +2, True), "B+02"), + ((2, +15, True), "B+15"), + ((2, +111, True), "B+111"), + ] + + for (base, exp_val, capitalize), expected_output in cases: + actual_output = exponents.get_standard_exp_str( + base=base, + exp_val=exp_val, + capitalize=capitalize, + ) + with self.subTest( + base=base, + exp_val=exp_val, + capitalize=capitalize, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_superscript_exp_str(self): + cases: list[tuple[tuple[int, int], str]] = [ + ((10, -10), "×10⁻¹⁰"), + ((10, -1), "×10⁻¹"), + ((10, 0), "×10⁰"), + ((10, 1), "×10¹"), + ((10, 2), "×10²"), + ((10, 3), "×10³"), + ((10, 4), "×10⁴"), + ((10, 5), "×10⁵"), + ((10, 6), "×10⁶"), + ((10, 7), "×10⁷"), + ((10, 8), "×10⁸"), + ((10, 9), "×10⁹"), + ((10, 10), "×10¹⁰"), + ((2, -10), "×2⁻¹⁰"), + ((2, -1), "×2⁻¹"), + ((2, 0), "×2⁰"), + ((2, 1), "×2¹"), + ((2, 2), "×2²"), + ((2, 3), "×2³"), + ((2, 4), "×2⁴"), + ((2, 5), "×2⁵"), + ((2, 6), "×2⁶"), + ((2, 7), "×2⁷"), + ((2, 8), "×2⁸"), + ((2, 9), "×2⁹"), + ((2, 10), "×2¹⁰"), + ] + + for (base, exp_val), expected_output in cases: + actual_output = exponents.get_superscript_exp_str(base, exp_val) + with self.subTest( + base=base, + exp_val=exp_val, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_val_unc_exp(self): + cases: list[GetValUncExpCase] = [ + ((Decimal("123"), Decimal("1"), ExpModeEnum.FIXEDPOINT, AutoExpVal), 0), + ((Decimal("123"), Decimal("1"), ExpModeEnum.SCIENTIFIC, AutoExpVal), 2), + ((Decimal("-123"), Decimal("1"), ExpModeEnum.SCIENTIFIC, AutoExpVal), 2), + ((Decimal("1"), Decimal("123"), ExpModeEnum.SCIENTIFIC, AutoExpVal), 2), + ((Decimal("nan"), Decimal("123"), ExpModeEnum.SCIENTIFIC, AutoExpVal), 2), + ((Decimal("123"), Decimal("nan"), ExpModeEnum.SCIENTIFIC, AutoExpVal), 2), + ((Decimal("nan"), Decimal("nan"), ExpModeEnum.SCIENTIFIC, AutoExpVal), 0), + ] + + for (val, unc, exp_mode, input_exp), expected_output in cases: + actual_output = exponents.get_val_unc_exp( + val=val, + unc=unc, + exp_mode=exp_mode, + input_exp=input_exp, + ) + with self.subTest( + val=val, + unc=unc, + exp_mode=exp_mode, + input_exp=input_exp, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_exp_str(self): + cases: list[tuple[dict[str, Any], str]] = [ + ( + { + "exp_val": 0, + "exp_mode": ExpModeEnum.FIXEDPOINT, + "exp_format": ExpFormatEnum.STANDARD, + "extra_si_prefixes": {}, + "extra_iec_prefixes": {}, + "extra_parts_per_forms": {}, + "capitalize": False, + "superscript": False, + }, + "", + ), + ( + { + "exp_val": 0, + "exp_mode": ExpModeEnum.PERCENT, + "exp_format": ExpFormatEnum.STANDARD, + "extra_si_prefixes": {}, + "extra_iec_prefixes": {}, + "extra_parts_per_forms": {}, + "capitalize": False, + "superscript": False, + }, + "%", + ), + ( + { + "exp_val": -2, + "exp_mode": ExpModeEnum.ENGINEERING_SHIFTED, + "exp_format": ExpFormatEnum.PREFIX, + "extra_si_prefixes": {-2: "c"}, + "extra_iec_prefixes": {}, + "extra_parts_per_forms": {}, + "capitalize": False, + "superscript": False, + }, + " c", + ), + ( + { + "exp_val": -3, + "exp_mode": ExpModeEnum.ENGINEERING_SHIFTED, + "exp_format": ExpFormatEnum.PREFIX, + "extra_si_prefixes": {-3: None}, + "extra_iec_prefixes": {}, + "extra_parts_per_forms": {}, + "capitalize": False, + "superscript": False, + }, + "e-03", + ), + ( + { + "exp_val": 0, + "exp_mode": ExpModeEnum.ENGINEERING_SHIFTED, + "exp_format": ExpFormatEnum.PARTS_PER, + "extra_si_prefixes": {}, + "extra_iec_prefixes": {}, + "extra_parts_per_forms": {}, + "capitalize": False, + "superscript": False, + }, + "", + ), + ( + { + "exp_val": -3, + "exp_mode": ExpModeEnum.ENGINEERING_SHIFTED, + "exp_format": ExpFormatEnum.STANDARD, + "extra_si_prefixes": {-3: None}, + "extra_iec_prefixes": {}, + "extra_parts_per_forms": {}, + "capitalize": False, + "superscript": True, + }, + "×10⁻³", + ), + ( + { + "exp_val": 20, + "exp_mode": ExpModeEnum.BINARY_IEC, + "exp_format": ExpFormatEnum.PREFIX, + "extra_si_prefixes": {}, + "extra_iec_prefixes": {}, + "extra_parts_per_forms": {}, + "capitalize": False, + "superscript": False, + }, + " Mi", + ), + ] + + for kwargs, expected_output in cases: + actual_output = exponents.get_exp_str(**kwargs) + with self.subTest(**kwargs): + self.assertEqual(expected_output, actual_output) diff --git a/tests/unit/format_utils/test_grouping.py b/tests/unit/format_utils/test_grouping.py new file mode 100644 index 00000000..f9178ca2 --- /dev/null +++ b/tests/unit/format_utils/test_grouping.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import unittest + +from sciform.format_utils import grouping + + +class TestGrouping(unittest.TestCase): + def test_add_grouping_chars_forward(self): + cases: list[tuple[tuple[str, str], str]] = [ + (("1", "_"), "1"), + (("12", "_"), "12"), + (("123", "_"), "123"), + (("1234", "_"), "123_4"), + (("12345", "_"), "123_45"), + (("123456", "_"), "123_456"), + (("1234567", "_"), "123_456_7"), + (("12345678", "_"), "123_456_78"), + (("1", " "), "1"), + (("12", " "), "12"), + (("123", " "), "123"), + (("1234", " "), "123 4"), + (("12345", " "), "123 45"), + (("123456", " "), "123 456"), + (("1234567", " "), "123 456 7"), + (("12345678", " "), "123 456 78"), + ] + + reverse = False + for (num_str, group_char), expected_output in cases: + kwargs = { + "num_str": num_str, + "group_char": group_char, + "reverse": reverse, + } + actual_output = grouping.add_group_chars(**kwargs) + with self.subTest(**kwargs): + self.assertEqual(expected_output, actual_output) + + def test_add_grouping_chars_reverse(self): + cases: list[tuple[tuple[str, str], str]] = [ + (("1", "_"), "1"), + (("12", "_"), "12"), + (("123", "_"), "123"), + (("1234", "_"), "1_234"), + (("12345", "_"), "12_345"), + (("123456", "_"), "123_456"), + (("1234567", "_"), "1_234_567"), + (("12345678", "_"), "12_345_678"), + (("1", " "), "1"), + (("12", " "), "12"), + (("123", " "), "123"), + (("1234", " "), "1 234"), + (("12345", " "), "12 345"), + (("123456", " "), "123 456"), + (("1234567", " "), "1 234 567"), + (("12345678", " "), "12 345 678"), + ] + + reverse = True + for (num_str, group_char), expected_output in cases: + kwargs = { + "num_str": num_str, + "group_char": group_char, + "reverse": reverse, + } + actual_output = grouping.add_group_chars(**kwargs) + with self.subTest(**kwargs): + self.assertEqual(expected_output, actual_output) + + def test_add_separators(self): + cases: list[tuple[str, str]] = [ + ("1", "1"), + ("12", "12"), + ("123", "123"), + ("1234", "1u234"), + ("12345", "12u345"), + ("123456", "123u456"), + ("1234567", "1u234u567"), + ("1234567.7", "1u234u567d7"), + ("1234567.76", "1u234u567d76"), + ("1234567.765", "1u234u567d765"), + ("1234567.7654", "1u234u567d765l4"), + ("1234567.76543", "1u234u567d765l43"), + ("1234567.765432", "1u234u567d765l432"), + ("1234567.7654321", "1u234u567d765l432l1"), + ("+ 1234567.7654321", "+ 1u234u567d765l432l1"), + ("+0001234567.7654321", "+0u001u234u567d765l432l1"), + (" 1234567.7654321", " 1u234u567d765l432l1"), + ] + + separator_cases: list[tuple[str, str, str]] = [ + ("", ".", ""), + (",", ".", ""), + (" ", ".", ""), + ("_", ".", ""), + ("_", ".", "_"), + ("_", ".", " "), + (" ", ".", " "), + ("", ",", ""), + (".", ",", ""), + (" ", ",", ""), + ("_", ",", ""), + ("_", ",", "_"), + ("_", ",", " "), + (" ", ",", " "), + ] + + for num_str, pre_expected_ouput in cases: + for upper_separator, decimal_separator, lower_separator in separator_cases: + expected_output = pre_expected_ouput + expected_output = expected_output.replace("u", upper_separator) + expected_output = expected_output.replace("d", decimal_separator) + expected_output = expected_output.replace("l", lower_separator) + kwargs = { + "num_str": num_str, + "upper_separator": upper_separator, + "decimal_separator": decimal_separator, + "lower_separator": lower_separator, + } + actual_output = grouping.add_separators(**kwargs) + with self.subTest(**kwargs): + self.assertEqual(expected_output, actual_output) diff --git a/tests/unit/format_utils/test_make_strings.py b/tests/unit/format_utils/test_make_strings.py new file mode 100644 index 00000000..f5067ee1 --- /dev/null +++ b/tests/unit/format_utils/test_make_strings.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import unittest +from decimal import Decimal +from typing import Any + +from sciform.format_utils import make_strings +from sciform.options.option_types import SignModeEnum + +ConstructNumStrCase = tuple[ + tuple[Decimal, int, int, SignModeEnum, str], + str, +] + + +class TestMakeStrings(unittest.TestCase): + def test_get_sign_str(self): + cases: list[tuple[tuple[Decimal, SignModeEnum], str]] = [ + ((Decimal("inf"), SignModeEnum.NEGATIVE), ""), + ((Decimal("1"), SignModeEnum.NEGATIVE), ""), + ((Decimal("-1"), SignModeEnum.NEGATIVE), "-"), + ((Decimal("-inf"), SignModeEnum.NEGATIVE), "-"), + ((Decimal("0"), SignModeEnum.NEGATIVE), ""), + ((Decimal("nan"), SignModeEnum.NEGATIVE), ""), + ((Decimal("inf"), SignModeEnum.ALWAYS), "+"), + ((Decimal("1"), SignModeEnum.ALWAYS), "+"), + ((Decimal("-1"), SignModeEnum.ALWAYS), "-"), + ((Decimal("-inf"), SignModeEnum.ALWAYS), "-"), + ((Decimal("0"), SignModeEnum.ALWAYS), " "), + ((Decimal("nan"), SignModeEnum.ALWAYS), " "), + ((Decimal("inf"), SignModeEnum.SPACE), " "), + ((Decimal("1"), SignModeEnum.SPACE), " "), + ((Decimal("-1"), SignModeEnum.SPACE), "-"), + ((Decimal("-inf"), SignModeEnum.SPACE), "-"), + ((Decimal("0"), SignModeEnum.SPACE), " "), + ((Decimal("nan"), SignModeEnum.SPACE), " "), + ] + + for (num, sign_mode), expected_output in cases: + kwargs = {"num": num, "sign_mode": sign_mode} + with self.subTest(**kwargs): + actual_output = make_strings.get_sign_str(**kwargs) + self.assertEqual(expected_output, actual_output) + + def test_get_sign_str_invalid(self): + self.assertRaises( + ValueError, + make_strings.get_sign_str, + Decimal("1.0"), + "+", + ) + + def test_get_pad_str(self): + cases: list[tuple[tuple[str, int, int], str]] = [ + (("0", 3, 0), ""), + (("0", 3, 1), ""), + (("0", 3, 2), ""), + (("0", 3, 3), ""), + (("0", 3, 4), "0"), + (("0", 3, 5), "00"), + (("0", 3, 6), "000"), + ((" ", 3, 0), ""), + ((" ", 3, 1), ""), + ((" ", 3, 2), ""), + ((" ", 3, 3), ""), + ((" ", 3, 4), " "), + ((" ", 3, 5), " "), + ((" ", 3, 6), " "), + ] + + for input_options, expected_output in cases: + left_pad_char, top_dec_place, top_padded_dec_place = input_options + kwargs = { + "left_pad_char": left_pad_char, + "top_dec_place": top_dec_place, + "top_padded_dec_place": top_padded_dec_place, + } + with self.subTest(**kwargs): + actual_output = make_strings.get_pad_str(**kwargs) + self.assertEqual(expected_output, actual_output) + + def test_get_abs_num_str_by_bottom_dec_place(self): + cases: list[tuple[tuple[Decimal, int], str]] = [ + ((Decimal("123456.654"), 2), "123457"), + ((Decimal("123456.654"), 1), "123457"), + ((Decimal("123456.654"), 0), "123457"), + ((Decimal("123456.654"), -1), "123456.7"), + ((Decimal("123456.654"), -2), "123456.65"), + ((Decimal("123456.654"), -3), "123456.654"), + ((Decimal("123456.654"), -4), "123456.6540"), + ((Decimal("123456.654"), -5), "123456.65400"), + ] + + for (num, target_bottom_dec_place), expected_output in cases: + kwargs = { + "num": num, + "target_bottom_dec_place": target_bottom_dec_place, + } + with self.subTest(**kwargs): + actual_output = make_strings.get_abs_num_str_by_bottom_dec_place( + **kwargs, + ) + self.assertEqual(expected_output, actual_output) + + def test_construct_num_str(self): + cases: list[ConstructNumStrCase] = [ + ( + (Decimal("1"), 3, -3, SignModeEnum.ALWAYS, "0"), + "+0001.000", + ), + ( + (Decimal("1"), 3, -3, SignModeEnum.SPACE, "0"), + " 0001.000", + ), + ( + (Decimal("1"), 3, -3, SignModeEnum.SPACE, " "), + " 1.000", + ), + ] + + for input_options, expected_output in cases: + ( + num, + target_top_dec_place, + target_bottom_dec_place, + sign_mode, + left_pad_char, + ) = input_options + kwargs = { + "num": num, + "target_top_dec_place": target_top_dec_place, + "target_bottom_dec_place": target_bottom_dec_place, + "sign_mode": sign_mode, + "left_pad_char": left_pad_char, + } + with self.subTest(**kwargs): + actual_output = make_strings.construct_num_str(**kwargs) + self.assertEqual(expected_output, actual_output) + + def test_construct_val_unc_str(self): + cases: list[tuple[dict[str, Any], str]] = [ + ( + { + "val_mantissa_str": "123.456", + "unc_mantissa_str": "0.123", + "val_mantissa": Decimal("123.456"), + "unc_mantissa": Decimal("0.123"), + "decimal_separator": ".", + "paren_uncertainty": False, + "pm_whitespace": True, + "paren_uncertainty_trim": False, + }, + "123.456 ± 0.123", + ), + ( + { + "val_mantissa_str": "123.456", + "unc_mantissa_str": "0.123", + "val_mantissa": Decimal("123.456"), + "unc_mantissa": Decimal("0.123"), + "decimal_separator": ".", + "paren_uncertainty": False, + "pm_whitespace": False, + "paren_uncertainty_trim": False, + }, + "123.456±0.123", + ), + ( + { + "val_mantissa_str": "123,456", + "unc_mantissa_str": "0,123", + "val_mantissa": Decimal("123.456"), + "unc_mantissa": Decimal("0.123"), + "decimal_separator": ",", + "paren_uncertainty": False, + "pm_whitespace": True, + "paren_uncertainty_trim": False, + }, + "123,456 ± 0,123", + ), + ( + { + "val_mantissa_str": "123.456", + "unc_mantissa_str": "0.123", + "val_mantissa": Decimal("123.456"), + "unc_mantissa": Decimal("0.123"), + "decimal_separator": ".", + "paren_uncertainty": False, + "pm_whitespace": True, + "paren_uncertainty_trim": False, + }, + "123.456 ± 0.123", + ), + ( + { + "val_mantissa_str": "123,456", + "unc_mantissa_str": "0,123", + "val_mantissa": Decimal("123.456"), + "unc_mantissa": Decimal("0.123"), + "decimal_separator": ",", + "paren_uncertainty": True, + "pm_whitespace": True, + "paren_uncertainty_trim": False, + }, + "123,456(0,123)", + ), + ( + { + "val_mantissa_str": "123,456", + "unc_mantissa_str": "0,123", + "val_mantissa": Decimal("123.456"), + "unc_mantissa": Decimal("0.123"), + "decimal_separator": ",", + "paren_uncertainty": True, + "pm_whitespace": True, + "paren_uncertainty_trim": True, + }, + "123,456(123)", + ), + ( + { + "val_mantissa_str": "123,456,789.123_456", + "unc_mantissa_str": "0.123_456", + "val_mantissa": Decimal("123456789.123456"), + "unc_mantissa": Decimal("0.123456"), + "decimal_separator": ",", + "paren_uncertainty": True, + "pm_whitespace": True, + "paren_uncertainty_trim": False, + }, + "123,456,789.123_456(0.123_456)", + ), + ( + { + "val_mantissa_str": "123,456,789.123_456", + "unc_mantissa_str": "0.123_456", + "val_mantissa": Decimal("123456789.123456"), + "unc_mantissa": Decimal("0.123456"), + "decimal_separator": ",", + "paren_uncertainty": True, + "pm_whitespace": True, + "paren_uncertainty_trim": True, + }, + "123,456,789.123_456(123456)", + ), + ( + { + "val_mantissa_str": "0.123", + "unc_mantissa_str": "123,456.456", + "val_mantissa": Decimal("0.123"), + "unc_mantissa": Decimal("123456.456"), + "decimal_separator": ",", + "paren_uncertainty": True, + "pm_whitespace": True, + "paren_uncertainty_trim": True, + }, + "0.123(123,456.456)", + ), + ] + + for kwargs, expected_output in cases: + with self.subTest(**kwargs): + actual_output = make_strings.construct_val_unc_str(**kwargs) + self.assertEqual(expected_output, actual_output) + + def test_construct_val_unc_exp_str(self): + cases: list[tuple[tuple[str, str, bool], str]] = [ + (("123 ± 12", "", False), "123 ± 12"), + (("123 ± 12", "e+04", False), "(123 ± 12)e+04"), + (("123(12)", "e+04", True), "123(12)e+04"), + ] + + for (val_unc_str, exp_str, paren_uncertainty), expected_output in cases: + kwargs = { + "val_unc_str": val_unc_str, + "exp_str": exp_str, + "paren_uncertainty": paren_uncertainty, + } + with self.subTest(**kwargs): + actual_output = make_strings.construct_val_unc_exp_str( + val_unc_str=val_unc_str, + exp_str=exp_str, + paren_uncertainty=paren_uncertainty, + ) + self.assertEqual(expected_output, actual_output) diff --git a/tests/unit/format_utils/test_number_utils.py b/tests/unit/format_utils/test_number_utils.py new file mode 100644 index 00000000..e7e024e2 --- /dev/null +++ b/tests/unit/format_utils/test_number_utils.py @@ -0,0 +1,1031 @@ +from __future__ import annotations + +from decimal import Decimal +from math import isnan +from typing import Union + +from sciform import AutoExpVal +from sciform.format_utils import numbers +from sciform.options.option_types import ExpModeEnum + +from tests import NanTestCase + +MantissaExpBaseCase = list[ + tuple[ + tuple[Decimal, ExpModeEnum, Union[int, type(AutoExpVal)]], + tuple[Decimal, int, int], + ] +] + + +class TestNumberUtils(NanTestCase): + def assertNanEqual(self, first, second, msg=None): # noqa: N802 + if isnan(first): + self.assertTrue(isnan(second), msg=msg) + else: + self.assertEqual(first, second, msg=msg) + + def test_get_top_dec_place(self): + cases = [ + (Decimal("1e-20"), -20), + (Decimal("1e-06"), -6), + (Decimal("1e-05"), -5), + (Decimal("1e-04"), -4), + (Decimal("1e-03"), -3), + (Decimal("1e-02"), -2), + (Decimal("1e-01"), -1), + (Decimal("1e00"), 0), + (Decimal("1e+01"), 1), + (Decimal("1e+02"), 2), + (Decimal("1e+03"), 3), + (Decimal("1e+04"), 4), + (Decimal("1e+05"), 5), + (Decimal("1e+06"), 6), + (Decimal("1e+20"), 20), + (Decimal("nan"), 0), + (Decimal("-inf"), 0), + (Decimal("0"), 0), + ] + + for base_number, expected_output in cases: + for factor in [Decimal(1), Decimal(5)]: + test_number = factor * base_number + actual_output = numbers.get_top_dec_place(test_number) + with self.subTest( + base_number=base_number, + test_number=test_number, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual( + expected_output, + actual_output, + ) + + def test_get_top_dec_place_binary(self): + cases = [ + (Decimal("32"), 5), + (Decimal("64"), 6), + (Decimal("nan"), 0), + (Decimal("-inf"), 0), + (Decimal("0"), 0), + ] + + for base_number, expected_output in cases: + for factor in [Decimal(1), Decimal(1.5)]: + test_number = factor * base_number + actual_output = numbers.get_top_dec_place_binary(test_number) + with self.subTest( + base_number=base_number, + test_number=test_number, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual( + expected_output, + actual_output, + ) + + def test_get_bottom_dec_place(self): + cases: list[tuple[Decimal, int]] = [ + # Unnormalized: + (Decimal("10000000.0000001"), -7), + (Decimal("10000000.0000010"), -6), + (Decimal("10000000.0000100"), -5), + (Decimal("10000000.0001000"), -4), + (Decimal("10000000.0010000"), -3), + (Decimal("10000000.0100000"), -2), + (Decimal("10000000.1000000"), -1), + (Decimal("10000001.0000000"), 0), + (Decimal("10000010.0000000"), +1), + (Decimal("10000100.0000000"), +2), + (Decimal("10001000.0000000"), +3), + (Decimal("10010000.0000000"), +4), + (Decimal("10100000.0000000"), +5), + (Decimal("11000000.0000000"), +6), + (Decimal("10000000.0000000"), +7), + # Normalized: + (Decimal("1.000001"), -6), + (Decimal("1.00001"), -5), + (Decimal("1.0001"), -4), + (Decimal("1.001"), -3), + (Decimal("1.01"), -2), + (Decimal("1.1"), -1), + (Decimal("1"), 0), + (Decimal("1000001E+1"), +1), + (Decimal("100001E+2"), +2), + (Decimal("10001E+3"), +3), + (Decimal("1001E+4"), +4), + (Decimal("101E+5"), +5), + (Decimal("11E+6"), +6), + (Decimal("1E+7"), +7), + (Decimal("nan"), 0), + (Decimal("inf"), 0), + (Decimal("-inf"), 0), + ] + + for base_number, expected_output in cases: + for factor in [Decimal(1), Decimal(5)]: + test_number = factor * base_number + actual_output = numbers.get_bottom_dec_place(test_number) + with self.subTest( + base_number=base_number, + test_number=test_number, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual( + expected_output, + actual_output, + ) + + def test_get_val_unc_top_dec_place(self): + cases: list[tuple[tuple[Decimal, Decimal, int], int]] = [ + ((Decimal("123"), Decimal("0.456"), 0), 0), + ((Decimal("123"), Decimal("0.456"), 1), 1), + ((Decimal("123"), Decimal("0.456"), 2), 2), + ((Decimal("123"), Decimal("0.456"), 3), 3), + ((Decimal("123"), Decimal("0.456"), 4), 4), + ((Decimal("123"), Decimal("0.456"), 5), 5), + ((Decimal("123"), Decimal("0.456"), 6), 6), + ((Decimal("0.456"), Decimal("123"), 0), 0), + ((Decimal("0.456"), Decimal("123"), 1), 1), + ((Decimal("0.456"), Decimal("123"), 2), 2), + ((Decimal("0.456"), Decimal("123"), 3), 3), + ((Decimal("0.456"), Decimal("123"), 4), 4), + ((Decimal("0.456"), Decimal("123"), 5), 5), + ((Decimal("0.456"), Decimal("123"), 6), 6), + ((Decimal("0.000123"), Decimal("0.000000456"), -1), 0), + ((Decimal("0.000000456"), Decimal("0.000123"), -1), 0), + ] + + left_pad_matching = False + for (val, unc, input_top_dec_place), expected_output in cases: + actual_output = numbers.get_val_unc_top_dec_place( + val_mantissa=val, + unc_mantissa=unc, + input_top_dec_place=input_top_dec_place, + left_pad_matching=left_pad_matching, + ) + with self.subTest( + val_mantissa=val, + unc_mantissa=unc, + input_top_dec_place=input_top_dec_place, + left_pad_matching=left_pad_matching, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_val_unc_top_dec_place_left_pad_match(self): + cases: list[tuple[tuple[Decimal, Decimal, int], int]] = [ + ((Decimal("123"), Decimal("0.456"), 0), 2), + ((Decimal("123"), Decimal("0.456"), 1), 2), + ((Decimal("123"), Decimal("0.456"), 2), 2), + ((Decimal("123"), Decimal("0.456"), 3), 3), + ((Decimal("123"), Decimal("0.456"), 4), 4), + ((Decimal("123"), Decimal("0.456"), 5), 5), + ((Decimal("123"), Decimal("0.456"), 6), 6), + ((Decimal("0.456"), Decimal("123"), 0), 2), + ((Decimal("0.456"), Decimal("123"), 1), 2), + ((Decimal("0.456"), Decimal("123"), 2), 2), + ((Decimal("0.456"), Decimal("123"), 3), 3), + ((Decimal("0.456"), Decimal("123"), 4), 4), + ((Decimal("0.456"), Decimal("123"), 5), 5), + ((Decimal("0.456"), Decimal("123"), 6), 6), + ((Decimal("0.000123"), Decimal("0.000000456"), -1), 0), + ((Decimal("0.000000456"), Decimal("0.000123"), -1), 0), + ] + + left_pad_matching = True + for (val, unc, input_top_dec_place), expected_output in cases: + actual_output = numbers.get_val_unc_top_dec_place( + val_mantissa=val, + unc_mantissa=unc, + input_top_dec_place=input_top_dec_place, + left_pad_matching=left_pad_matching, + ) + with self.subTest( + val_mantissa=val, + unc_mantissa=unc, + input_top_dec_place=input_top_dec_place, + left_pad_matching=left_pad_matching, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_fixed_exp(self): + self.assertEqual(0, numbers.get_fixed_exp(0)) + self.assertEqual(0, numbers.get_fixed_exp(AutoExpVal)) + self.assertRaises( + ValueError, + numbers.get_fixed_exp, + 3, + ) + + def test_get_scientific_exp(self): + cases: list[tuple[tuple[Decimal, int | type(AutoExpVal)], int]] = [ + ((Decimal("1000000"), AutoExpVal), 6), + ((Decimal("100000"), AutoExpVal), 5), + ((Decimal("10000"), AutoExpVal), 4), + ((Decimal("1000"), AutoExpVal), 3), + ((Decimal("100"), AutoExpVal), 2), + ((Decimal("10"), AutoExpVal), 1), + ((Decimal("1"), AutoExpVal), 0), + ((Decimal("0.1"), AutoExpVal), -1), + ((Decimal("0.01"), AutoExpVal), -2), + ((Decimal("0.001"), AutoExpVal), -3), + ((Decimal("0.0001"), AutoExpVal), -4), + ((Decimal("0.00001"), AutoExpVal), -5), + ((Decimal("0.000001"), AutoExpVal), -6), + ((Decimal("1000000"), 3), 3), + ((Decimal("100000"), 3), 3), + ((Decimal("10000"), 3), 3), + ((Decimal("1000"), 3), 3), + ((Decimal("100"), 3), 3), + ((Decimal("10"), 3), 3), + ((Decimal("1"), 3), 3), + ((Decimal("0.1"), 3), 3), + ((Decimal("0.01"), 3), 3), + ((Decimal("0.001"), 3), 3), + ((Decimal("0.0001"), 3), 3), + ((Decimal("0.00001"), 3), 3), + ((Decimal("0.000001"), 3), 3), + ] + + for (base_number, input_exp), expected_output in cases: + for factor in [Decimal(1), Decimal(5)]: + test_number = factor * base_number + actual_output = numbers.get_scientific_exp( + test_number, + input_exp, + ) + with self.subTest( + base_number=base_number, + test_number=test_number, + input_exp=input_exp, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_engineering_exp(self): + cases: list[tuple[tuple[Decimal, int | type(AutoExpVal)], int]] = [ + ((Decimal("1000000"), AutoExpVal), 6), + ((Decimal("100000"), AutoExpVal), 3), + ((Decimal("10000"), AutoExpVal), 3), + ((Decimal("1000"), AutoExpVal), 3), + ((Decimal("100"), AutoExpVal), 0), + ((Decimal("10"), AutoExpVal), 0), + ((Decimal("1"), AutoExpVal), 0), + ((Decimal("0.1"), AutoExpVal), -3), + ((Decimal("0.01"), AutoExpVal), -3), + ((Decimal("0.001"), AutoExpVal), -3), + ((Decimal("0.0001"), AutoExpVal), -6), + ((Decimal("0.00001"), AutoExpVal), -6), + ((Decimal("0.000001"), AutoExpVal), -6), + ((Decimal("1000000"), 3), 3), + ((Decimal("100000"), 3), 3), + ((Decimal("10000"), 3), 3), + ((Decimal("1000"), 3), 3), + ((Decimal("100"), 3), 3), + ((Decimal("10"), 3), 3), + ((Decimal("1"), 3), 3), + ((Decimal("0.1"), 3), 3), + ((Decimal("0.01"), 3), 3), + ((Decimal("0.001"), 3), 3), + ((Decimal("0.0001"), 3), 3), + ((Decimal("0.00001"), 3), 3), + ((Decimal("0.000001"), 3), 3), + ] + + shifted = False + for (base_number, input_exp), expected_output in cases: + for factor in [Decimal(1), Decimal(5)]: + test_number = factor * base_number + actual_output = numbers.get_engineering_exp( + base_number, + input_exp, + shifted=shifted, + ) + with self.subTest( + base_number=base_number, + test_number=test_number, + input_exp=input_exp, + shifted=shifted, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_engineering_shifted_exp(self): + cases: list[tuple[tuple[Decimal, int | type(AutoExpVal)], int]] = [ + ((Decimal("1000000"), AutoExpVal), 6), + ((Decimal("100000"), AutoExpVal), 6), + ((Decimal("10000"), AutoExpVal), 3), + ((Decimal("1000"), AutoExpVal), 3), + ((Decimal("100"), AutoExpVal), 3), + ((Decimal("10"), AutoExpVal), 0), + ((Decimal("1"), AutoExpVal), 0), + ((Decimal("0.1"), AutoExpVal), 0), + ((Decimal("0.01"), AutoExpVal), -3), + ((Decimal("0.001"), AutoExpVal), -3), + ((Decimal("0.0001"), AutoExpVal), -3), + ((Decimal("0.00001"), AutoExpVal), -6), + ((Decimal("0.000001"), AutoExpVal), -6), + ((Decimal("1000000"), 3), 3), + ((Decimal("100000"), 3), 3), + ((Decimal("10000"), 3), 3), + ((Decimal("1000"), 3), 3), + ((Decimal("100"), 3), 3), + ((Decimal("10"), 3), 3), + ((Decimal("1"), 3), 3), + ((Decimal("0.1"), 3), 3), + ((Decimal("0.01"), 3), 3), + ((Decimal("0.001"), 3), 3), + ((Decimal("0.0001"), 3), 3), + ((Decimal("0.00001"), 3), 3), + ((Decimal("0.000001"), 3), 3), + ] + + shifted = True + for (base_number, input_exp), expected_output in cases: + for factor in [Decimal(1), Decimal(5)]: + test_number = factor * base_number + actual_output = numbers.get_engineering_exp( + base_number, + input_exp, + shifted=shifted, + ) + with self.subTest( + base_number=base_number, + test_number=test_number, + input_exp=input_exp, + shifted=shifted, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_engineering_exp_bad_input_exp(self): + self.assertRaises( + ValueError, + numbers.get_engineering_exp, + Decimal("1.0"), + 2, + ) + + def test_get_binary_exp(self): + cases: list[tuple[tuple[Decimal, int | type(AutoExpVal)], int]] = [ + ((Decimal("0.0625"), AutoExpVal), -4), + ((Decimal("0.125"), AutoExpVal), -3), + ((Decimal("0.25"), AutoExpVal), -2), + ((Decimal("0.5"), AutoExpVal), -1), + ((Decimal("1"), AutoExpVal), 0), + ((Decimal("2"), AutoExpVal), 1), + ((Decimal("4"), AutoExpVal), 2), + ((Decimal("8"), AutoExpVal), 3), + ((Decimal("16"), AutoExpVal), 4), + ((Decimal("32"), AutoExpVal), 5), + ((Decimal("64"), AutoExpVal), 6), + ((Decimal("0.0625"), 3), 3), + ((Decimal("0.125"), 3), 3), + ((Decimal("0.25"), 3), 3), + ((Decimal("0.5"), 3), 3), + ((Decimal("1"), 3), 3), + ((Decimal("2"), 3), 3), + ((Decimal("4"), 3), 3), + ((Decimal("8"), 3), 3), + ((Decimal("16"), 3), 3), + ((Decimal("32"), 3), 3), + ((Decimal("64"), 3), 3), + ] + + iec: bool = False + for (base_number, input_exp), expected_output in cases: + for factor in [Decimal(1), Decimal(1.5)]: + test_number = factor * base_number + actual_output = numbers.get_binary_exp( + test_number, + input_exp, + iec=iec, + ) + with self.subTest( + base_number=base_number, + test_number=test_number, + input_exp=input_exp, + iec=iec, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_binary_iec_exp(self): + cases: list[tuple[tuple[Decimal, int | type(AutoExpVal)], int]] = [ + ((Decimal("0.0625"), AutoExpVal), -10), + ((Decimal("0.125"), AutoExpVal), -10), + ((Decimal("0.25"), AutoExpVal), -10), + ((Decimal("0.5"), AutoExpVal), -10), + ((Decimal("1"), AutoExpVal), 0), + ((Decimal("2"), AutoExpVal), 0), + ((Decimal("4"), AutoExpVal), 0), + ((Decimal("8"), AutoExpVal), 0), + ((Decimal("16"), AutoExpVal), 0), + ((Decimal("32"), AutoExpVal), 0), + ((Decimal("64"), AutoExpVal), 0), + ((Decimal("1024"), AutoExpVal), 10), + ((Decimal(2**20), AutoExpVal), 20), + ((Decimal(2**30), AutoExpVal), 30), + ((Decimal("0.0625"), 10), 10), + ((Decimal("0.125"), 10), 10), + ((Decimal("0.25"), 10), 10), + ((Decimal("0.5"), 10), 10), + ((Decimal("1"), 10), 10), + ((Decimal("2"), 10), 10), + ((Decimal("4"), 10), 10), + ((Decimal("8"), 10), 10), + ((Decimal("16"), 10), 10), + ((Decimal("32"), 10), 10), + ((Decimal("64"), 10), 10), + ] + + iec: bool = True + for (base_number, input_exp), expected_output in cases: + for factor in [Decimal(1), Decimal(1.5)]: + test_number = factor * base_number + actual_output = numbers.get_binary_exp( + test_number, + input_exp, + iec=iec, + ) + with self.subTest( + base_number=base_number, + test_number=test_number, + input_exp=input_exp, + iec=iec, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_binary_iec_bad_input_exp(self): + self.assertRaises( + ValueError, + numbers.get_binary_exp, + Decimal("1.5"), + 5, + iec=True, + ) + + def test_get_mantissa_exp_base(self): + cases: MantissaExpBaseCase = [ + ( + (Decimal("123456"), ExpModeEnum.FIXEDPOINT, AutoExpVal), + (Decimal("123456"), 0, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.FIXEDPOINT, AutoExpVal), + (Decimal("1234.56"), 0, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.FIXEDPOINT, AutoExpVal), + (Decimal("12.3456"), 0, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.FIXEDPOINT, AutoExpVal), + (Decimal("0.123456"), 0, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.FIXEDPOINT, AutoExpVal), + (Decimal("0.00123456"), 0, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.FIXEDPOINT, AutoExpVal), + (Decimal("0.0000123456"), 0, 10), + ), + ( + (Decimal("123456"), ExpModeEnum.PERCENT, AutoExpVal), + (Decimal("123456"), 0, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.PERCENT, AutoExpVal), + (Decimal("1234.56"), 0, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.PERCENT, AutoExpVal), + (Decimal("12.3456"), 0, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.PERCENT, AutoExpVal), + (Decimal("0.123456"), 0, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.PERCENT, AutoExpVal), + (Decimal("0.00123456"), 0, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.PERCENT, AutoExpVal), + (Decimal("0.0000123456"), 0, 10), + ), + ( + (Decimal("123456"), ExpModeEnum.SCIENTIFIC, AutoExpVal), + (Decimal("1.23456"), 5, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.SCIENTIFIC, AutoExpVal), + (Decimal("1.23456"), 3, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.SCIENTIFIC, AutoExpVal), + (Decimal("1.23456"), 1, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.SCIENTIFIC, AutoExpVal), + (Decimal("1.23456"), -1, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.SCIENTIFIC, AutoExpVal), + (Decimal("1.23456"), -3, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.SCIENTIFIC, AutoExpVal), + (Decimal("1.23456"), -5, 10), + ), + ( + (Decimal("123456"), ExpModeEnum.ENGINEERING, AutoExpVal), + (Decimal("123.456"), 3, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.ENGINEERING, AutoExpVal), + (Decimal("1.23456"), 3, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.ENGINEERING, AutoExpVal), + (Decimal("12.3456"), 0, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.ENGINEERING, AutoExpVal), + (Decimal("123.456"), -3, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.ENGINEERING, AutoExpVal), + (Decimal("1.23456"), -3, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.ENGINEERING, AutoExpVal), + (Decimal("12.3456"), -6, 10), + ), + ( + (Decimal("123456"), ExpModeEnum.ENGINEERING_SHIFTED, AutoExpVal), + (Decimal("0.123456"), 6, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.ENGINEERING_SHIFTED, AutoExpVal), + (Decimal("1.23456"), 3, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.ENGINEERING_SHIFTED, AutoExpVal), + (Decimal("12.3456"), 0, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.ENGINEERING_SHIFTED, AutoExpVal), + (Decimal("0.123456"), 0, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.ENGINEERING_SHIFTED, AutoExpVal), + (Decimal("1.23456"), -3, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.ENGINEERING_SHIFTED, AutoExpVal), + (Decimal("12.3456"), -6, 10), + ), + ( + (Decimal(1 * 2**-10), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.0"), -10, 2), + ), + ( + (Decimal(1.5 * 2**-10), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.5"), -10, 2), + ), + ( + (Decimal(1 * 2**-5), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.0"), -5, 2), + ), + ( + (Decimal(1.5 * 2**-5), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.5"), -5, 2), + ), + ( + (Decimal(1 * 2**0), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.0"), 0, 2), + ), + ( + (Decimal(1.5 * 2**0), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.5"), 0, 2), + ), + ( + (Decimal(1 * 2**5), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.0"), 5, 2), + ), + ( + (Decimal(1.5 * 2**5), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.5"), 5, 2), + ), + ( + (Decimal(1 * 2**10), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.0"), 10, 2), + ), + ( + (Decimal(1.5 * 2**10), ExpModeEnum.BINARY, AutoExpVal), + (Decimal("1.5"), 10, 2), + ), + ( + (Decimal(1 * 2**-10), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("1.0"), -10, 2), + ), + ( + (Decimal(1.5 * 2**-10), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("1.5"), -10, 2), + ), + ( + (Decimal(1 * 2**-5), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("32"), -10, 2), + ), + ( + (Decimal(1.5 * 2**-5), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("48"), -10, 2), + ), + ( + (Decimal(1 * 2**0), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("1.0"), 0, 2), + ), + ( + (Decimal(1.5 * 2**0), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("1.5"), 0, 2), + ), + ( + (Decimal(1 * 2**5), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("32"), 0, 2), + ), + ( + (Decimal(1.5 * 2**5), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("48"), 0, 2), + ), + ( + (Decimal(1 * 2**10), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("1.0"), 10, 2), + ), + ( + (Decimal(1.5 * 2**10), ExpModeEnum.BINARY_IEC, AutoExpVal), + (Decimal("1.5"), 10, 2), + ), + ( + (Decimal("nan"), ExpModeEnum.FIXEDPOINT, AutoExpVal), + (Decimal("nan"), 0, 10), + ), + ( + (Decimal("inf"), ExpModeEnum.SCIENTIFIC, AutoExpVal), + (Decimal("inf"), 0, 10), + ), + ( + (Decimal("-inf"), ExpModeEnum.SCIENTIFIC, 5), + (Decimal("-inf"), 5, 10), + ), + ] + + for input_data, output_data in cases: + num, exp_mode, input_exp = input_data + expected_mantissa, expected_exp, expected_base = output_data + actual_mantissa, actual_exp, actual_base = numbers.get_mantissa_exp_base( + num, + exp_mode, + input_exp, + ) + with self.subTest( + num=num, + exp_mode=exp_mode, + input_exp=input_exp, + expected_mantissa=expected_mantissa, + actual_mantissa=actual_mantissa, + expected_exp=expected_exp, + actual_exp=actual_exp, + expected_base=expected_base, + actual_base=actual_base, + ): + self.assertNanEqual(expected_mantissa, actual_mantissa) + self.assertEqual(expected_exp, actual_exp) + self.assertEqual(expected_base, actual_base) + + def test_get_mantissa_exp_base_input_exp(self): + cases: MantissaExpBaseCase = [ + ( + (Decimal("123456"), ExpModeEnum.FIXEDPOINT, 0), + (Decimal("123456"), 0, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.FIXEDPOINT, 0), + (Decimal("1234.56"), 0, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.FIXEDPOINT, 0), + (Decimal("12.3456"), 0, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.FIXEDPOINT, 0), + (Decimal("0.123456"), 0, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.FIXEDPOINT, 0), + (Decimal("0.00123456"), 0, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.FIXEDPOINT, 0), + (Decimal("0.0000123456"), 0, 10), + ), + ( + (Decimal("123456"), ExpModeEnum.PERCENT, 0), + (Decimal("123456"), 0, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.PERCENT, 0), + (Decimal("1234.56"), 0, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.PERCENT, 0), + (Decimal("12.3456"), 0, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.PERCENT, 0), + (Decimal("0.123456"), 0, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.PERCENT, 0), + (Decimal("0.00123456"), 0, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.PERCENT, 0), + (Decimal("0.0000123456"), 0, 10), + ), + ( + (Decimal("123456"), ExpModeEnum.SCIENTIFIC, 2), + (Decimal("1234.56"), 2, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.SCIENTIFIC, 2), + (Decimal("12.3456"), 2, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.SCIENTIFIC, 2), + (Decimal("0.123456"), 2, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.SCIENTIFIC, 2), + (Decimal("0.00123456"), 2, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.SCIENTIFIC, 2), + (Decimal("0.0000123456"), 2, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.SCIENTIFIC, 2), + (Decimal("0.000000123456"), 2, 10), + ), + ( + (Decimal("123456"), ExpModeEnum.ENGINEERING, 3), + (Decimal("123.456"), 3, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.ENGINEERING, 3), + (Decimal("1.23456"), 3, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.ENGINEERING, 3), + (Decimal("0.0123456"), 3, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.ENGINEERING, 3), + (Decimal("0.000123456"), 3, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.ENGINEERING, 3), + (Decimal("0.00000123456"), 3, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.ENGINEERING, 3), + (Decimal("0.0000000123456"), 3, 10), + ), + ( + (Decimal("123456"), ExpModeEnum.ENGINEERING_SHIFTED, 3), + (Decimal("123.456"), 3, 10), + ), + ( + (Decimal("1234.56"), ExpModeEnum.ENGINEERING_SHIFTED, 3), + (Decimal("1.23456"), 3, 10), + ), + ( + (Decimal("12.3456"), ExpModeEnum.ENGINEERING_SHIFTED, 3), + (Decimal("0.0123456"), 3, 10), + ), + ( + (Decimal("0.123456"), ExpModeEnum.ENGINEERING_SHIFTED, 3), + (Decimal("0.000123456"), 3, 10), + ), + ( + (Decimal("0.00123456"), ExpModeEnum.ENGINEERING_SHIFTED, 3), + (Decimal("0.00000123456"), 3, 10), + ), + ( + (Decimal("0.0000123456"), ExpModeEnum.ENGINEERING_SHIFTED, 3), + (Decimal("0.0000000123456"), 3, 10), + ), + ( + (Decimal(1 * 2**-10), ExpModeEnum.BINARY, 5), + (Decimal("3.0517578125e-05"), 5, 2), + ), + ( + (Decimal(1.5 * 2**-10), ExpModeEnum.BINARY, 5), + (Decimal("4.57763671875e-05"), 5, 2), + ), + ( + (Decimal(1 * 2**-5), ExpModeEnum.BINARY, 5), + (Decimal("0.0009765625"), 5, 2), + ), + ( + (Decimal(1.5 * 2**-5), ExpModeEnum.BINARY, 5), + (Decimal("0.00146484375"), 5, 2), + ), + ( + (Decimal(1 * 2**0), ExpModeEnum.BINARY, 5), + (Decimal("0.03125"), 5, 2), + ), + ( + (Decimal(1.5 * 2**0), ExpModeEnum.BINARY, 5), + (Decimal("0.046875"), 5, 2), + ), + ( + (Decimal(1 * 2**5), ExpModeEnum.BINARY, 5), + (Decimal("1.0"), 5, 2), + ), + ( + (Decimal(1.5 * 2**5), ExpModeEnum.BINARY, 5), + (Decimal("1.5"), 5, 2), + ), + ( + (Decimal(1 * 2**10), ExpModeEnum.BINARY, 5), + (Decimal("32"), 5, 2), + ), + ( + (Decimal(1.5 * 2**10), ExpModeEnum.BINARY, 5), + (Decimal("48"), 5, 2), + ), + ( + (Decimal(1 * 2**-10), ExpModeEnum.BINARY_IEC, 10), + (Decimal(1 * 2**-20), 10, 2), + ), + ( + (Decimal(1.5 * 2**-10), ExpModeEnum.BINARY_IEC, 10), + (Decimal(1.5 * 2**-20), 10, 2), + ), + ( + (Decimal(1 * 2**-5), ExpModeEnum.BINARY_IEC, 10), + (Decimal(1 * 2**-15), 10, 2), + ), + ( + (Decimal(1.5 * 2**-5), ExpModeEnum.BINARY_IEC, 10), + (Decimal(1.5 * 2**-15), 10, 2), + ), + ( + (Decimal(1 * 2**0), ExpModeEnum.BINARY_IEC, 10), + (Decimal(1 * 2**-10), 10, 2), + ), + ( + (Decimal(1.5 * 2**0), ExpModeEnum.BINARY_IEC, 10), + (Decimal(1.5 * 2**-10), 10, 2), + ), + ( + (Decimal(1 * 2**5), ExpModeEnum.BINARY_IEC, 10), + (Decimal(1 * 2**-5), 10, 2), + ), + ( + (Decimal(1.5 * 2**5), ExpModeEnum.BINARY_IEC, 10), + (Decimal(1.5 * 2**-5), 10, 2), + ), + ( + (Decimal(1 * 2**10), ExpModeEnum.BINARY_IEC, 10), + (Decimal("1.0"), 10, 2), + ), + ( + (Decimal(1.5 * 2**10), ExpModeEnum.BINARY_IEC, 10), + (Decimal("1.5"), 10, 2), + ), + ] + + for input_data, output_data in cases: + num, exp_mode, input_exp = input_data + expected_mantissa, expected_exp, expected_base = output_data + actual_mantissa, actual_exp, actual_base = numbers.get_mantissa_exp_base( + num, + exp_mode, + input_exp, + ) + with self.subTest( + num=num, + exp_mode=exp_mode, + input_exp=input_exp, + expected_mantissa=expected_mantissa, + actual_mantissa=actual_mantissa, + expected_exp=expected_exp, + actual_exp=actual_exp, + expected_base=expected_base, + actual_base=actual_base, + ): + self.assertEqual(expected_mantissa, actual_mantissa) + self.assertEqual(expected_exp, actual_exp) + self.assertEqual(expected_base, actual_base) + + def test_get_mantissa_exp_base_invalid_exp_mode(self): + self.assertRaises( + ValueError, + numbers.get_mantissa_exp_base, + Decimal("3"), + exp_mode="fixed_point", + input_exp=3, + ) + + def test_parse_mantissa_from_ascii_exp_str(self): + cases = [ + ("123.456e+03", "123.456"), + ("123456e+03", "123456"), + ("123_456e+03", "123_456"), + ("123_456.789 876e+03", "123_456.789 876"), + ("123.456", "123.456"), + ("123456", "123456"), + ("123_456", "123_456"), + ("123_456.789 876", "123_456.789 876"), + ("1E+03", "1"), + ("(nan)b-4", "nan"), + ("(nan)B-4", "nan"), + ("inf", "inf"), + ("-inf", "-inf"), + ] + for input_string, expected_output in cases: + actual_output = numbers.parse_mantissa_from_ascii_exp_str(input_string) + with self.subTest( + input_string=input_string, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_parse_mantissa_from_ascii_exp_str_invalid(self): + cases = [ + "1.2c+03", + "(nan)", + "(1.2 ± 0.3)e+04", + ] + + for input_str in cases: + with self.subTest(input_str=input_str): + self.assertRaises( + ValueError, + numbers.parse_mantissa_from_ascii_exp_str, + input_str, + ) + + def test_get_mantissa_exp_base_invalid_input(self): + with self.subTest(msg="fixed_point_set_exp"): + self.assertRaises( + ValueError, + numbers.get_mantissa_exp_base, + num=Decimal(3), + exp_mode=ExpModeEnum.FIXEDPOINT, + input_exp=1, + ) + + with self.subTest(msg="engineering_set_exp"): + self.assertRaises( + ValueError, + numbers.get_mantissa_exp_base, + num=Decimal(3), + exp_mode=ExpModeEnum.ENGINEERING, + input_exp=1, + ) + + with self.subTest(msg="binary_iec_set_exp"): + self.assertRaises( + ValueError, + numbers.get_mantissa_exp_base, + num=Decimal(3), + exp_mode=ExpModeEnum.BINARY_IEC, + input_exp=3, + ) + + with self.subTest(msg="bad_exp_mode"): + self.assertRaises( + ValueError, + numbers.get_mantissa_exp_base, + num=Decimal(3), + exp_mode="eng", + input_exp=3, + ) diff --git a/tests/unit/format_utils/test_rounding_utils.py b/tests/unit/format_utils/test_rounding_utils.py new file mode 100644 index 00000000..3b468282 --- /dev/null +++ b/tests/unit/format_utils/test_rounding_utils.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +from decimal import Decimal +from typing import Union + +from sciform.format_utils import rounding +from sciform.options.option_types import AutoDigits, RoundModeEnum + +from tests import NanTestCase + +RoundDecPlaceCase = tuple[ + tuple[Decimal, RoundModeEnum, Union[int, type[AutoDigits]]], + int, +] + +RoundValUncCase = tuple[ + tuple[Decimal, Decimal, Union[int, type[AutoDigits]], bool], + tuple[Decimal, Decimal, int], +] + + +class TestRounding(NanTestCase): + def test_get_pdg_round_digit(self): + cases: list[tuple[Decimal, int]] = [ + (Decimal("10.0"), 0), + (Decimal("20.0"), 0), + (Decimal("30.0"), 0), + (Decimal("35.4"), 0), + (Decimal("35.5"), 1), + (Decimal("40.0"), 1), + (Decimal("50.0"), 1), + (Decimal("60.0"), 1), + (Decimal("70.0"), 1), + (Decimal("80.0"), 1), + (Decimal("90.0"), 1), + (Decimal("94.9"), 1), + (Decimal("95.0"), 1), + (Decimal("99.9"), 1), + (Decimal("100"), 1), + (Decimal("200"), 1), + (Decimal("300"), 1), + (Decimal("354"), 1), + (Decimal("355"), 2), + (Decimal("400"), 2), + (Decimal("500"), 2), + (Decimal("600"), 2), + (Decimal("700"), 2), + (Decimal("800"), 2), + (Decimal("900"), 2), + (Decimal("949"), 2), + (Decimal("950"), 2), + (Decimal("999"), 2), + (Decimal("1000"), 2), + (Decimal("2000"), 2), + (Decimal("3000"), 2), + (Decimal("3540"), 2), + (Decimal("3550"), 3), + (Decimal("4000"), 3), + (Decimal("5000"), 3), + (Decimal("6000"), 3), + (Decimal("7000"), 3), + (Decimal("8000"), 3), + (Decimal("9000"), 3), + (Decimal("9490"), 3), + (Decimal("9500"), 3), + (Decimal("9990"), 3), + ] + + for number, expected_output in cases: + actual_output = rounding.get_pdg_round_digit(number) + with self.subTest( + number=number, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_pdg_round_digit_invalid(self): + self.assertRaises( + ValueError, + rounding.get_pdg_round_digit, + Decimal("nan"), + ) + + def test_get_round_dec_place(self): + cases: list[RoundDecPlaceCase] = [ + ((Decimal("123456"), RoundModeEnum.SIG_FIG, 2), 4), + ((Decimal("12345.6"), RoundModeEnum.SIG_FIG, 2), 3), + ((Decimal("1234.56"), RoundModeEnum.SIG_FIG, 2), 2), + ((Decimal("123.456"), RoundModeEnum.SIG_FIG, 2), 1), + ((Decimal("12.3456"), RoundModeEnum.SIG_FIG, 2), 0), + ((Decimal("1.23456"), RoundModeEnum.SIG_FIG, 2), -1), + ((Decimal("0.123456"), RoundModeEnum.SIG_FIG, 2), -2), + ((Decimal("0.0123456"), RoundModeEnum.SIG_FIG, 2), -3), + ((Decimal("0.00123456"), RoundModeEnum.SIG_FIG, 2), -4), + ((Decimal("0.000123456"), RoundModeEnum.SIG_FIG, 2), -5), + ((Decimal("123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("12345.6"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("1234.56"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("123.456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("12.3456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("1.23456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("0.123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("0.0123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("0.00123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("0.000123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("12345.6"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("1234.56"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("123.456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("12.3456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("1.23456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("0.123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("0.0123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("0.00123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("0.000123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("123456"), RoundModeEnum.SIG_FIG, AutoDigits), 0), + ((Decimal("12345.6"), RoundModeEnum.SIG_FIG, AutoDigits), -1), + ((Decimal("1234.56"), RoundModeEnum.SIG_FIG, AutoDigits), -2), + ((Decimal("123.456"), RoundModeEnum.SIG_FIG, AutoDigits), -3), + ((Decimal("12.3456"), RoundModeEnum.SIG_FIG, AutoDigits), -4), + ((Decimal("1.23456"), RoundModeEnum.SIG_FIG, AutoDigits), -5), + ((Decimal("0.123456"), RoundModeEnum.SIG_FIG, AutoDigits), -6), + ((Decimal("0.0123456"), RoundModeEnum.SIG_FIG, AutoDigits), -7), + ((Decimal("0.00123456"), RoundModeEnum.SIG_FIG, AutoDigits), -8), + ((Decimal("0.000123456"), RoundModeEnum.SIG_FIG, AutoDigits), -9), + ((Decimal("123456"), RoundModeEnum.DEC_PLACE, AutoDigits), 0), + ((Decimal("12345.6"), RoundModeEnum.DEC_PLACE, AutoDigits), -1), + ((Decimal("1234.56"), RoundModeEnum.DEC_PLACE, AutoDigits), -2), + ((Decimal("123.456"), RoundModeEnum.DEC_PLACE, AutoDigits), -3), + ((Decimal("12.3456"), RoundModeEnum.DEC_PLACE, AutoDigits), -4), + ((Decimal("1.23456"), RoundModeEnum.DEC_PLACE, AutoDigits), -5), + ((Decimal("0.123456"), RoundModeEnum.DEC_PLACE, AutoDigits), -6), + ((Decimal("0.0123456"), RoundModeEnum.DEC_PLACE, AutoDigits), -7), + ((Decimal("0.00123456"), RoundModeEnum.DEC_PLACE, AutoDigits), -8), + ((Decimal("0.000123456"), RoundModeEnum.DEC_PLACE, AutoDigits), -9), + ] + + for input_data, expected_output in cases: + num, round_mode, ndigits = input_data + pdg_sig_figs = False + actual_output = rounding.get_round_dec_place( + num, + round_mode, + ndigits, + pdg_sig_figs=pdg_sig_figs, + ) + with self.subTest( + num=num, + round_mode=round_mode, + ndigits=ndigits, + pdg_sig_figs=pdg_sig_figs, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_round_dec_place_pdg(self): + cases: list[RoundDecPlaceCase] = [ + ((Decimal("123456"), RoundModeEnum.SIG_FIG, 2), 4), + ((Decimal("12345.6"), RoundModeEnum.SIG_FIG, 2), 3), + ((Decimal("1234.56"), RoundModeEnum.SIG_FIG, 2), 2), + ((Decimal("123.456"), RoundModeEnum.SIG_FIG, 2), 1), + ((Decimal("12.3456"), RoundModeEnum.SIG_FIG, 2), 0), + ((Decimal("1.23456"), RoundModeEnum.SIG_FIG, 2), -1), + ((Decimal("0.123456"), RoundModeEnum.SIG_FIG, 2), -2), + ((Decimal("0.0123456"), RoundModeEnum.SIG_FIG, 2), -3), + ((Decimal("0.00123456"), RoundModeEnum.SIG_FIG, 2), -4), + ((Decimal("0.000123456"), RoundModeEnum.SIG_FIG, 2), -5), + ((Decimal("123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("12345.6"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("1234.56"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("123.456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("12.3456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("1.23456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("0.123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("0.0123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("0.00123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("0.000123456"), RoundModeEnum.DEC_PLACE, 2), -2), + ((Decimal("123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("12345.6"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("1234.56"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("123.456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("12.3456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("1.23456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("0.123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("0.0123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("0.00123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("0.000123456"), RoundModeEnum.DEC_PLACE, -2), 2), + ((Decimal("123456"), RoundModeEnum.SIG_FIG, AutoDigits), 4), + ((Decimal("12345.6"), RoundModeEnum.SIG_FIG, AutoDigits), 3), + ((Decimal("1234.56"), RoundModeEnum.SIG_FIG, AutoDigits), 2), + ((Decimal("123.456"), RoundModeEnum.SIG_FIG, AutoDigits), 1), + ((Decimal("12.3456"), RoundModeEnum.SIG_FIG, AutoDigits), 0), + ((Decimal("1.23456"), RoundModeEnum.SIG_FIG, AutoDigits), -1), + ((Decimal("0.123456"), RoundModeEnum.SIG_FIG, AutoDigits), -2), + ((Decimal("0.0123456"), RoundModeEnum.SIG_FIG, AutoDigits), -3), + ((Decimal("0.00123456"), RoundModeEnum.SIG_FIG, AutoDigits), -4), + ((Decimal("0.000123456"), RoundModeEnum.SIG_FIG, AutoDigits), -5), + ((Decimal("123456"), RoundModeEnum.DEC_PLACE, AutoDigits), 0), + ((Decimal("12345.6"), RoundModeEnum.DEC_PLACE, AutoDigits), -1), + ((Decimal("1234.56"), RoundModeEnum.DEC_PLACE, AutoDigits), -2), + ((Decimal("123.456"), RoundModeEnum.DEC_PLACE, AutoDigits), -3), + ((Decimal("12.3456"), RoundModeEnum.DEC_PLACE, AutoDigits), -4), + ((Decimal("1.23456"), RoundModeEnum.DEC_PLACE, AutoDigits), -5), + ((Decimal("0.123456"), RoundModeEnum.DEC_PLACE, AutoDigits), -6), + ((Decimal("0.0123456"), RoundModeEnum.DEC_PLACE, AutoDigits), -7), + ((Decimal("0.00123456"), RoundModeEnum.DEC_PLACE, AutoDigits), -8), + ((Decimal("0.000123456"), RoundModeEnum.DEC_PLACE, AutoDigits), -9), + ] + + for input_data, expected_output in cases: + num, round_mode, ndigits = input_data + pdg_sig_figs = True + actual_output = rounding.get_round_dec_place( + num, + round_mode, + ndigits, + pdg_sig_figs=pdg_sig_figs, + ) + with self.subTest( + num=num, + round_mode=round_mode, + ndigits=ndigits, + pdg_sig_figs=pdg_sig_figs, + expected_output=expected_output, + actual_output=actual_output, + ): + self.assertEqual(expected_output, actual_output) + + def test_get_round_dec_place_invalid(self): + self.assertRaises( + ValueError, + rounding.get_round_dec_place, + Decimal("1"), + "sig_fig", + 2, + ) + + def test_round_val_unc(self): + cases: list[RoundValUncCase] = [ + ( + (Decimal("123"), Decimal("0.456"), 1, False), + (Decimal("123.0"), Decimal("0.5"), -1), + ), + ( + (Decimal("123"), Decimal("0.456"), 4, False), + (Decimal("123.0000"), Decimal("0.4560"), -4), + ), + ( + (Decimal("123"), Decimal("0.456"), 1, True), + (Decimal("123.0"), Decimal("0.5"), -1), + ), + ( + (Decimal("123"), Decimal("0.456"), 4, True), + (Decimal("123.0"), Decimal("0.5"), -1), + ), + ( + (Decimal("0.456"), Decimal("123"), 1, False), + (Decimal("0"), Decimal("100"), 2), + ), + ( + (Decimal("0.456"), Decimal("123"), 4, False), + (Decimal("0.5"), Decimal("123.0"), -1), + ), + ( + (Decimal("0.456"), Decimal("123"), 4, True), + (Decimal("0"), Decimal("120"), 1), + ), + ( + (Decimal("123"), Decimal("nan"), 4, False), + (Decimal("123.0"), Decimal("nan"), -1), + ), + ( + (Decimal("nan"), Decimal("123"), 4, False), + (Decimal("nan"), Decimal("123.0"), -1), + ), + ( + (Decimal("nan"), Decimal("inf"), 4, False), + (Decimal("nan"), Decimal("inf"), 0), + ), + ( + (Decimal("123"), Decimal("nan"), 4, True), + (Decimal("123.0"), Decimal("nan"), -1), + ), + ( + (Decimal("nan"), Decimal("123"), 4, True), + (Decimal("nan"), Decimal("120"), 1), + ), + ( + (Decimal("nan"), Decimal("inf"), 4, True), + (Decimal("nan"), Decimal("inf"), 0), + ), + ] + + for input_data, output_data in cases: + val, unc, ndigits, use_pdg_sig_figs = input_data + ( + expected_val_rounded, + expected_unc_rounded, + expected_round_digit, + ) = output_data + ( + actual_val_rounded, + actual_unc_rounded, + actual_round_digit, + ) = rounding.round_val_unc( + val, + unc, + ndigits, + use_pdg_sig_figs=use_pdg_sig_figs, + ) + with self.subTest( + val=val, + unc=unc, + ndigits=ndigits, + use_pdg_sig_figs=use_pdg_sig_figs, + ): + self.assertNanEqual(expected_val_rounded, actual_val_rounded) + self.assertNanEqual(expected_unc_rounded, actual_unc_rounded) + self.assertEqual(expected_round_digit, actual_round_digit) diff --git a/tests/unit/test_invalid_options.py b/tests/unit/test_invalid_options.py index 8965c4bc..76c0ffc5 100644 --- a/tests/unit/test_invalid_options.py +++ b/tests/unit/test_invalid_options.py @@ -2,17 +2,6 @@ from decimal import Decimal from sciform import Formatter -from sciform.format_utils.exponents import get_translation_dict -from sciform.format_utils.make_strings import ( - get_sign_str, -) -from sciform.format_utils.numbers import ( - get_mantissa_exp_base, - get_top_digit, - get_top_digit_binary, - parse_mantissa_from_ascii_exp_str, -) -from sciform.format_utils.rounding import get_round_digit from sciform.formatting.number_formatting import format_non_finite from sciform.formatting.output_conversion import _make_exp_str, convert_sciform_format from sciform.options import option_types @@ -162,82 +151,6 @@ def test_format_non_finite(self): finalize_input_options(InputOptions()), ) - def test_get_top_digit_infinite(self): - self.assertEqual(get_top_digit(Decimal("nan")), 0) - - def test_get_top_digit_binary_infinite(self): - self.assertEqual(get_top_digit_binary(Decimal("nan")), 0) - - def test_get_mantissa_exp_base_fixed_point_set_exp(self): - self.assertRaises( - ValueError, - get_mantissa_exp_base, - num=Decimal(3), - exp_mode=option_types.ExpModeEnum.FIXEDPOINT, - input_exp=1, - ) - - def test_get_mantissa_exp_base_engineering_set_exp(self): - self.assertRaises( - ValueError, - get_mantissa_exp_base, - num=Decimal(3), - exp_mode=option_types.ExpModeEnum.ENGINEERING, - input_exp=1, - ) - - def test_get_mantissa_exp_base_binary_iec_set_exp(self): - self.assertRaises( - ValueError, - get_mantissa_exp_base, - num=Decimal(3), - exp_mode=option_types.ExpModeEnum.BINARY_IEC, - input_exp=3, - ) - - def test_get_mantissa_exp_base_bad_exp_mode(self): - self.assertRaises( - ValueError, - get_mantissa_exp_base, - num=Decimal(3), - exp_mode="eng", - input_exp=3, - ) - - def test_get_sign_str_bad_sign_mode(self): - self.assertRaises(ValueError, get_sign_str, num=Decimal(1), sign_mode="space") - - def test_get_round_digit_bad_round_mode(self): - self.assertRaises( - ValueError, - get_round_digit, - num=Decimal(123.456), - round_mode="none", - ndigits=0, - ) - - def test_get_prefix_dict_bad_base(self): - self.assertRaises( - ValueError, - get_translation_dict, - exp_format=option_types.ExpFormatEnum.PREFIX, - base=3, - extra_si_prefixes={}, - extra_iec_prefixes={}, - extra_parts_per_forms={}, - ) - - def test_get_prefix_dict_bad_format(self): - self.assertRaises( - ValueError, - get_translation_dict, - exp_format="pref", - base=10, - extra_si_prefixes={}, - extra_iec_prefixes={}, - extra_parts_per_forms={}, - ) - def test_mode_str_to_enum_fail(self): self.assertRaises( ValueError, @@ -272,13 +185,6 @@ def test_make_exp_str_invalid_base(self): "ascii", ) - def test_parse_mantissa_from_ascii_exp_str(self): - self.assertRaises( - ValueError, - parse_mantissa_from_ascii_exp_str, - "1.23c+04", - ) - def test_invalid_translation_key(self): self.assertRaises( TypeError, diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py new file mode 100644 index 00000000..2e8bafab --- /dev/null +++ b/tests/unit/test_validation.py @@ -0,0 +1,142 @@ +import unittest + +from sciform.options.input_options import InputOptions + + +class TestValidation(unittest.TestCase): + def test_validate_exp_mode(self): + self.assertRaises( + ValueError, + InputOptions, + exp_mode="sci", + ) + + def test_validate_exp_val(self): + self.assertRaises( + TypeError, + InputOptions, + exp_val=1.0, + ) + + def test_validate_round_mode(self): + self.assertRaises( + ValueError, + InputOptions, + round_mode="sigfig", + ) + + def test_validate_ndigits(self): + self.assertRaises( + TypeError, + InputOptions, + ndigits=3.0, + ) + + def test_validate_upper_separator(self): + self.assertRaises( + ValueError, + InputOptions, + upper_separator="-", + ) + + def test_validate_decimal_separator(self): + self.assertRaises( + ValueError, + InputOptions, + decimal_separator="_", + ) + + def test_validate_lower_separator(self): + self.assertRaises( + ValueError, + InputOptions, + lower_separator=",", + ) + + def test_validate_upper_decimal_separator(self): + with self.subTest(msg="double_commas"): + self.assertRaises( + ValueError, + InputOptions, + upper_separator=",", + lower_separator=",", + ) + with self.subTest(msg="double_periods"): + self.assertRaises( + ValueError, + InputOptions, + upper_separator=".", + lower_separator=".", + ) + + def test_validate_sign_mode(self): + self.assertRaises( + ValueError, + InputOptions, + sign_mode="always", + ) + + def test_validate_left_pad_char(self): + self.assertRaises( + ValueError, + InputOptions, + left_pad_char="-", + ) + + def test_validate_left_pad_dec_place(self): + with self.subTest(msg="non_int"): + self.assertRaises( + TypeError, + InputOptions, + left_pad_dec_place=1.0, + ) + with self.subTest(msg="negative"): + self.assertRaises( + ValueError, + InputOptions, + left_pad_dec_place=-2, + ) + + def test_validate_exp_format(self): + self.assertRaises( + ValueError, + InputOptions, + exp_format="super", + ) + + def test_validate_extra_translations(self): + translation_keys = ( + "extra_si_prefixes", + "extra_iec_prefixes", + "extra_parts_per_forms", + ) + + invalid_keys = (-1.0, "3") + for translation_key in translation_keys: + for key in invalid_keys: + with self.subTest( + msg="test_non_int_key", + translation_key=translation_key, + key=key, + ): + kwargs = {translation_key: {key: "test"}} + self.assertRaises( + TypeError, + InputOptions, + **kwargs, + ) + + invalid_strs = ("3", "Å") + for translation_key in translation_keys: + for invalid_str in invalid_strs: + with self.subTest( + msg="non_alphabetic_val", + translation_key=translation_key, + invalid_str=invalid_str, + ): + kwargs = {translation_key: {3: invalid_str}} + self.assertRaises( + ValueError, + InputOptions, + **kwargs, + )