chore/adopt-local-tree #4
2 changed files with 148 additions and 0 deletions
feat: shared unit-aware formatting helpers
Pure presentation helpers (labels, number parse/format) shared by docks and dialogs; no Qt imports.
commit
f1d58273b7
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