diff --git a/src/otko/views/formatting.py b/src/otko/views/formatting.py new file mode 100644 index 0000000..1123a08 --- /dev/null +++ b/src/otko/views/formatting.py @@ -0,0 +1,97 @@ +# Ported from otko-development (MIT), (c) 2026 OTKO contributors +"""Small presentation helpers shared by docks and dialogs. + +Keeps labels, unit hints and numeric parsing in one place so every surface +names a field the same way and rejects bad input with the same message shape. +Pure helpers only — no Qt imports live here. +""" + +from __future__ import annotations + +from otko.core import UnitLabels, UnitSystem, labels_for + +__all__ = [ + "DOF_LABELS", + "MASS_LABELS", + "RESTRAINT_LABELS", + "format_number", + "parse_float", + "parse_int", + "unit_label", +] + +#: Six translational/rotational DOF labels, indexed 0-5. +DOF_LABELS: tuple[str, ...] = ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz") + +#: Six mass-component labels, indexed 0-5. +MASS_LABELS: tuple[str, ...] = ("Mx", "My", "Mz", "Ixx", "Iyy", "Izz") + +#: Human-readable restraint component names (restraint + rotation). +RESTRAINT_LABELS: tuple[str, ...] = ( + "Ux", + "Uy", + "Uz", + "Rx", + "Ry", + "Rz", +) + +#: Map a public quantity name to the :class:`UnitLabels` field that holds it. +_QUANTITY_FIELDS: dict[str, str] = { + "length": "length", + "force": "force", + "moment": "moment", + "stress": "stress", + "curvature": "curvature", + "rotation": "rotation", +} + + +def format_number(value: float) -> str: + """Return a compact, round-trippable string for ``value``. + + Integral floats lose the decimal point; everything else keeps up to six + significant digits so a round-trip through a table cell is exact enough + for engineering values. + """ + if value == int(value): + return str(int(value)) + return f"{value:.6g}" + + +def parse_float(text: str, field: str, *, allow_empty: bool = False) -> float: + """Parse ``text`` as a float or raise ``ValueError`` naming ``field``. + + An empty string is accepted when ``allow_empty`` is set and yields ``0.0`` + so a blank optional cell is not treated as an error. + """ + stripped = text.strip() + if not stripped: + if allow_empty: + return 0.0 + raise ValueError(f"{field} is required") + try: + return float(stripped) + except ValueError: + raise ValueError(f"{field} must be a number, got {stripped!r}") from None + + +def parse_int(text: str, field: str, *, allow_empty: bool = False) -> int: + """Parse ``text`` as an int or raise ``ValueError`` naming ``field``.""" + value = parse_float(text, field, allow_empty=allow_empty) + if value != int(value): + raise ValueError(f"{field} must be a whole number, got {text.strip()!r}") + return int(value) + + +def unit_label(quantity: str, system: UnitSystem) -> str: + """Return the display label for ``quantity`` in ``system``. + + ``quantity`` is one of ``length``, ``force``, ``moment``, ``stress``, + ``curvature`` or ``rotation``; anything else raises ``ValueError``. + """ + field = _QUANTITY_FIELDS.get(quantity) + if field is None: + raise ValueError(f"unknown unit quantity: {quantity!r}") + labels: UnitLabels = labels_for(system) + return getattr(labels, field) diff --git a/tests/unit/test_formatting.py b/tests/unit/test_formatting.py new file mode 100644 index 0000000..bc1c6db --- /dev/null +++ b/tests/unit/test_formatting.py @@ -0,0 +1,51 @@ +"""Unit tests for :mod:`otko.views.formatting` (no Qt required).""" + +from __future__ import annotations + +import pytest + +from otko.core import UnitSystem +from otko.views.formatting import ( + DOF_LABELS, + MASS_LABELS, + RESTRAINT_LABELS, + format_number, + parse_float, + parse_int, + unit_label, +) + + +def test_label_tuples() -> None: + assert DOF_LABELS == ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz") + assert MASS_LABELS == ("Mx", "My", "Mz", "Ixx", "Iyy", "Izz") + assert RESTRAINT_LABELS == ("Ux", "Uy", "Uz", "Rx", "Ry", "Rz") + + +def test_format_number() -> None: + assert format_number(50.0) == "50" + assert format_number(1.5) == "1.5" + + +def test_parse_float_required() -> None: + with pytest.raises(ValueError): + parse_float("", "x") + + +def test_parse_float_allow_empty() -> None: + assert parse_float("", "x", allow_empty=True) == 0.0 + + +def test_parse_int_rejects_fractional() -> None: + with pytest.raises(ValueError): + parse_int("1.5", "x") + + +def test_unit_label() -> None: + assert unit_label("force", UnitSystem.SI_M_N) == "N" + assert unit_label("moment", UnitSystem.US_FT_KIP) == "kip·ft" + + +def test_unit_label_unknown_quantity() -> None: + with pytest.raises(ValueError): + unit_label("temperature", UnitSystem.SI_M_N)