feat: shared unit-aware formatting helpers
Pure presentation helpers (labels, number parse/format) shared by docks and dialogs; no Qt imports.
This commit is contained in:
parent
1d21006281
commit
f1d58273b7
2 changed files with 148 additions and 0 deletions
97
src/otko/views/formatting.py
Normal file
97
src/otko/views/formatting.py
Normal file
|
|
@ -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)
|
||||||
51
tests/unit/test_formatting.py
Normal file
51
tests/unit/test_formatting.py
Normal file
|
|
@ -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)
|
||||||
Loading…
Reference in a new issue