otko/tests/unit/test_project.py
smillmorel d01a5957b7
Some checks failed
CI / lint (push) Has been cancelled
CI / test (macos-latest, 3.10) (push) Has been cancelled
CI / test (macos-latest, 3.11) (push) Has been cancelled
CI / test (macos-latest, 3.12) (push) Has been cancelled
CI / test (ubuntu-latest, 3.10) (push) Has been cancelled
CI / test (ubuntu-latest, 3.11) (push) Has been cancelled
CI / test (ubuntu-latest, 3.12) (push) Has been cancelled
CI / test (windows-latest, 3.10) (push) Has been cancelled
CI / test (windows-latest, 3.11) (push) Has been cancelled
CI / test (windows-latest, 3.12) (push) Has been cancelled
feat: consolidate units to Metric/Imperial with unit-aware dialogs
- core/units: two dominant systems (Metric m/kN, Imperial ft/kip) with
  display conversion helpers, legacy 4-system migration in ProjectMeta
- dialogs/docks: unit-aware material, section, case, load, grid and
  results labels; diagram renderer unit labels; render controls update
- docs: add consistent_units.md; regen examples/*.osmodel artifacts
- tests: update persistence/phase8/project/unit-labels for new systems
2026-09-08 16:33:30 -04:00

289 lines
9.4 KiB
Python

"""Unit tests for Project — the root aggregate."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from otko.core import (
AggregatorDOF,
ElasticBeamColumn,
ElasticSection,
FiberSection,
LinearTimeSeries,
NodalLoad,
Node,
PlainLoadPattern,
Project,
ProjectMeta,
RectangularPatch,
SectionAggregator,
Steel01,
StraightLayer,
TrussElement,
UnitSystem,
)
# ────────────────────────── construction ──────────────────────────
def test_empty_project_is_valid() -> None:
p = Project()
assert p.schema_version == 1
assert p.ndm == 3 and p.ndf == 6
assert p.nodes == []
assert p.meta.units == UnitSystem.METRIC
def test_invalid_ndm_ndf_pair_rejected() -> None:
with pytest.raises(ValidationError):
Project(ndm=2, ndf=6)
with pytest.raises(ValidationError):
Project(ndm=3, ndf=2)
def test_meta_round_trip() -> None:
p = Project(meta=ProjectMeta(name="Bridge", author="Ozan", units=UnitSystem.IMPERIAL))
assert p.meta.name == "Bridge"
assert p.meta.units == UnitSystem.IMPERIAL
# ────────────────────────── id allocation ──────────────────────────
def test_next_id_starts_at_one_when_empty() -> None:
p = Project()
assert p.next_node_id() == 1
assert p.next_element_id() == 1
def test_next_id_increments_above_max() -> None:
p = Project(
nodes=[
Node(id=1, coords=(0, 0, 0)),
Node(id=5, coords=(1, 0, 0)),
Node(id=3, coords=(0, 1, 0)),
]
)
assert p.next_node_id() == 6
def test_duplicate_node_ids_rejected() -> None:
with pytest.raises(ValidationError):
Project(nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=1, coords=(1, 0, 0))])
# ────────────────────────── lookups ──────────────────────────
def test_node_lookup() -> None:
n = Node(id=7, coords=(1, 2, 3))
p = Project(nodes=[n])
assert p.node(7) is n
def test_lookup_missing_raises_key_error() -> None:
p = Project()
with pytest.raises(KeyError, match="Node"):
p.node(99)
# ────────────────────────── reference validation ──────────────────────────
def test_validate_references_passes_on_consistent_model() -> None:
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
elements=[TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=1)],
)
p.validate_references() # no exception expected
def test_validate_references_catches_missing_node() -> None:
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0))],
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
elements=[TrussElement(id=1, nodes=(1, 99), area=0.01, material_id=1)],
)
with pytest.raises(ValueError, match="missing node 99"):
p.validate_references()
def test_validate_references_catches_missing_material() -> None:
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
elements=[TrussElement(id=1, nodes=(1, 2), area=0.01, material_id=99)],
)
with pytest.raises(ValueError, match="missing material 99"):
p.validate_references()
def test_validate_references_catches_missing_section_in_frame() -> None:
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0)), Node(id=2, coords=(1, 0, 0))],
elements=[ElasticBeamColumn(id=1, nodes=(1, 2), section_id=42)],
)
with pytest.raises(ValueError, match="missing section 42"):
p.validate_references()
def test_validate_references_catches_missing_time_series_in_pattern() -> None:
p = Project(
nodes=[Node(id=1, coords=(0, 0, 0))],
load_patterns=[
PlainLoadPattern(
id=1,
time_series_id=99,
nodal_loads=[NodalLoad(node_id=1, forces=(0, 0, -10, 0, 0, 0))],
)
],
)
with pytest.raises(ValueError, match="missing time series 99"):
p.validate_references()
def test_validate_references_catches_missing_material_in_fiber_patch() -> None:
p = Project(
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[
FiberSection(
id=1,
patches=[
RectangularPatch(
material_id=99,
n_fib_y=2,
n_fib_z=2,
y_i=-0.1,
z_i=-0.1,
y_j=0.1,
z_j=0.1,
)
],
)
],
)
with pytest.raises(ValueError, match="has a patch with missing material 99"):
p.validate_references()
def test_validate_references_catches_missing_material_in_fiber_layer() -> None:
p = Project(
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[
FiberSection(
id=1,
layers=[
StraightLayer(
material_id=99,
n_bars=3,
bar_area=1e-4,
y_start=-0.1,
z_start=-0.1,
y_end=0.1,
z_end=-0.1,
)
],
)
],
)
with pytest.raises(ValueError, match="has a layer with missing material 99"):
p.validate_references()
def test_validate_references_catches_missing_material_in_aggregator_pairing() -> None:
# Base section exists, so only the dangling pairing material should be flagged.
p = Project(
sections=[
ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6),
SectionAggregator(
id=2,
section_id=1,
pairings=[AggregatorDOF(material_id=99, dof="T")],
),
],
)
with pytest.raises(ValueError, match="aggregator pairing with missing material 99"):
p.validate_references()
def test_validate_references_catches_missing_base_section_in_aggregator() -> None:
# Pairing material exists, so only the dangling base section should be flagged.
p = Project(
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[
SectionAggregator(
id=2,
section_id=42,
pairings=[AggregatorDOF(material_id=1, dof="T")],
),
],
)
with pytest.raises(ValueError, match="missing base section 42"):
p.validate_references()
def test_validate_references_passes_on_valid_fiber_and_aggregator() -> None:
p = Project(
materials=[
Steel01(id=1, Fy=420e6, E0=200e9, b=0.01),
Steel01(id=2, Fy=420e6, E0=200e9, b=0.01),
],
sections=[
FiberSection(
id=1,
patches=[
RectangularPatch(
material_id=1,
n_fib_y=2,
n_fib_z=2,
y_i=-0.1,
z_i=-0.1,
y_j=0.1,
z_j=0.1,
)
],
layers=[
StraightLayer(
material_id=2,
n_bars=3,
bar_area=1e-4,
y_start=-0.1,
z_start=-0.1,
y_end=0.1,
z_end=-0.1,
)
],
),
SectionAggregator(
id=2,
section_id=1,
pairings=[AggregatorDOF(material_id=1, dof="T")],
),
],
)
p.validate_references() # no exception expected
# ────────────────────────── small smoke build ──────────────────────────
def test_full_truss_project_builds_and_validates() -> None:
p = Project(
ndm=2,
ndf=2,
nodes=[
Node(id=1, coords=(0, 0, 0), restraint=(True, True, False, False, False, False)),
Node(id=2, coords=(4, 0, 0), restraint=(False, True, False, False, False, False)),
Node(id=3, coords=(2, 3, 0)),
],
materials=[Steel01(id=1, Fy=420e6, E0=200e9, b=0.01)],
sections=[ElasticSection(id=1, E=200e9, A=0.01, Iz=8.33e-6)],
elements=[
TrussElement(id=1, nodes=(1, 3), area=1e-3, material_id=1),
TrussElement(id=2, nodes=(2, 3), area=1e-3, material_id=1),
TrussElement(id=3, nodes=(1, 2), area=1e-3, material_id=1),
],
time_series=[LinearTimeSeries(id=1)],
load_patterns=[
PlainLoadPattern(
id=1,
time_series_id=1,
nodal_loads=[NodalLoad(node_id=3, forces=(0, -1000, 0, 0, 0, 0))],
)
],
)
p.validate_references()
assert p.next_node_id() == 4
assert p.next_element_id() == 4