Catch-all for the intermixed residue of the unpushed otko-development work ported into this tree: combinations/console-dock/quick-guide wiring across commands, core, services, views and tests; repo-wide ruff-format normalization; README/CONTRIBUTING updates; and the toolbar default (both toolbars now open in the top area, quick guide text updated). Splitting this further would require hunk-level surgery with low confidence; the preceding commits in this branch isolate the self-contained features.
289 lines
9.4 KiB
Python
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.SI_M_N
|
|
|
|
|
|
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.SI_MM_N))
|
|
assert p.meta.name == "Bridge"
|
|
assert p.meta.units == UnitSystem.SI_MM_N
|
|
|
|
|
|
# ────────────────────────── 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
|