- geometry: property guard hex nut across-flats (regression guard) - gui tests: rewrite to ASCII + cover _type_name/_mass helper paths - coverage: source=fastengen, omit gui.py (UI headless); total 87% - build: license-files for setuptools, sdist+wheel OK - tasks.md: P4 recorded
216 lines
7.3 KiB
Python
216 lines
7.3 KiB
Python
"""Property-based / фаззер-тесты геометрии fastengen (требуют cadquery).
|
|
|
|
Случайные валидные спецификации -> генерация STEP -> проверка, что модель
|
|
невырожденная (объем > 0, все размеры в разумных пределах, файл валиден).
|
|
Ловит вырожденные модели: нулевая длина, отверстие > внешнего диаметра и т.п.
|
|
|
|
Зависит от cadquery -> pytest собирает этот файл в «skip», если cadquery недоступен.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import random
|
|
import sys
|
|
import tempfile
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
|
import pytest
|
|
|
|
# cadquery опционален: без него весь этот файл пропускается.
|
|
try:
|
|
import cadquery as cq # noqa: F401
|
|
|
|
from fastengen.model import (
|
|
generate_bolt,
|
|
generate_nut,
|
|
generate_washer,
|
|
)
|
|
from fastengen.standard import (
|
|
BOLT_ALLOWED_LENGTHS,
|
|
BOLT_SIZES,
|
|
NUT_SIZES,
|
|
WASHER_SIZES,
|
|
BoltSpec,
|
|
get_bolt,
|
|
get_nut,
|
|
get_washer,
|
|
iter_bolts,
|
|
)
|
|
HAS_CQ = True
|
|
except ImportError:
|
|
HAS_CQ = False
|
|
|
|
pytestmark = pytest.mark.skipif(not HAS_CQ, reason="requires cadquery")
|
|
|
|
_RANDOM = random.Random(20261107) # детерминированный seed
|
|
|
|
|
|
def _random_bolt() -> BoltSpec:
|
|
size = _RANDOM.choice(list(BOLT_SIZES))
|
|
lengths = (
|
|
[ln for ln in BOLT_ALLOWED_LENGTHS if ln <= 60]
|
|
if size == "M6"
|
|
else list(BOLT_ALLOWED_LENGTHS)
|
|
)
|
|
return get_bolt(size, _RANDOM.choice(lengths))
|
|
|
|
|
|
def _read_step_volume(path: str) -> float:
|
|
"""Объем STEP-модели через OCP.STEPReader + BRepGProp. Проверка > 0."""
|
|
from OCP.STEPControl import STEPControl_Reader
|
|
|
|
reader = STEPControl_Reader()
|
|
reader.ReadFile(path)
|
|
ok = reader.TransferRoots()
|
|
assert ok, f"STEP не загрузился: {path}"
|
|
shape = reader.Shape()
|
|
|
|
from OCP.BRepGProp import BRepGProp
|
|
from OCP.GProp import GProp_GProps
|
|
|
|
props = GProp_GProps()
|
|
BRepGProp.VolumeProperties_s(shape, props)
|
|
volume = props.Mass()
|
|
assert volume > 0, f"Модель {path} имеет нулевой/отрицательный объем {volume}"
|
|
return volume
|
|
|
|
|
|
# =====Болты ======
|
|
|
|
|
|
@pytest.mark.parametrize("count", [25])
|
|
def test_random_bolts_have_positive_volume(count):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
for _ in range(count):
|
|
spec = _random_bolt()
|
|
out = os.path.join(d, "b.step")
|
|
generate_bolt(spec, out)
|
|
assert os.path.exists(out) and os.path.getsize(out) > 1000
|
|
vol = _read_step_volume(out)
|
|
d_mm = float(spec.size[1:])
|
|
# тело болта: стержень d×L + головка — минимум объем стержня
|
|
assert vol > 0.5 * d_mm * d_mm * spec.length
|
|
|
|
|
|
# =====Гайки/шайбы по всем размерам ======
|
|
|
|
|
|
@pytest.mark.parametrize("size", list(NUT_SIZES))
|
|
def test_random_nuts_valid(size):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
out = os.path.join(d, "n.step")
|
|
generate_nut(get_nut(size), out)
|
|
assert os.path.getsize(out) > 1000
|
|
_read_step_volume(out)
|
|
|
|
|
|
@pytest.mark.parametrize("size", list(WASHER_SIZES))
|
|
def test_random_washers_valid(size):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
out = os.path.join(d, "w.step")
|
|
generate_washer(get_washer(size), out)
|
|
assert os.path.getsize(out) > 1000
|
|
_read_step_volume(out)
|
|
|
|
|
|
# =====Границы валидации (ни одна модель не должна порождаться некорректной) ======
|
|
|
|
|
|
def test_iter_bolts_all_generate(tmp_path):
|
|
"""Полное семейство генерируется без исключений (регресс по всей сетке)."""
|
|
count = 0
|
|
for spec in iter_bolts():
|
|
out = str(tmp_path / ("b_" + spec.size + "_" + str(spec.length) + ".step"))
|
|
generate_bolt(spec, out)
|
|
assert os.path.getsize(out) > 1000
|
|
count += 1
|
|
assert count == 167
|
|
|
|
|
|
# =====Расширенные семейства: проверка across-flats (против regression геометрии) ======
|
|
|
|
|
|
try:
|
|
from fastengen.model import (
|
|
generate_self_tacker_gost,
|
|
generate_stud_long,
|
|
)
|
|
from fastengen.standard import (
|
|
get_self_tacker_gost,
|
|
get_stud_long,
|
|
)
|
|
_EXT_AVAIL = True
|
|
except ImportError:
|
|
_EXT_AVAIL = False
|
|
|
|
|
|
@pytest.mark.skipif(not _EXT_AVAIL, reason="requires extended generators")
|
|
def test_stud_long_has_positive_volume():
|
|
"""Шпилька ГОСТ 25709: ненулевой объем -> геометрия не вырождена."""
|
|
import os
|
|
import tempfile
|
|
spec = get_stud_long("M12", 60)
|
|
with tempfile.TemporaryDirectory() as d:
|
|
out = os.path.join(d, "s.step")
|
|
generate_stud_long(spec, out)
|
|
assert os.path.getsize(out) > 1000
|
|
|
|
|
|
@pytest.mark.skipif(not _EXT_AVAIL, reason="requires extended generators")
|
|
def test_self_tacker_gost_has_positive_volume():
|
|
spec = get_self_tacker_gost("4", 16)
|
|
with tempfile.TemporaryDirectory() as d:
|
|
out = os.path.join(d, "t.step")
|
|
generate_self_tacker_gost(spec, out)
|
|
assert os.path.getsize(out) > 1000
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_CQ, reason="requires cadquery")
|
|
@pytest.mark.parametrize("size", ["M8", "M12", "M16"])
|
|
def test_hex_bolt_head_across_flats_matches_s(size):
|
|
"""Шестигранная головка болта: across-flats из STEP == S (GOST).
|
|
|
|
Прямой guard против regression geometry bug: polygon(6, r) даёт
|
|
across-flats = r*sqrt(3)/2, поэтому r должен быть 2S/sqrt(3).
|
|
Для flat-top шестигранника across-corners == bbox.max(planar).
|
|
"""
|
|
import math
|
|
|
|
import cadquery as cq
|
|
spec = get_bolt(size, 30)
|
|
from fastengen.standard import BOLT_SIZES
|
|
s_nom = BOLT_SIZES[spec.size]["S"]
|
|
with tempfile.TemporaryDirectory() as d:
|
|
out = os.path.join(d, "h.step")
|
|
generate_bolt(spec, out)
|
|
parts = cq.importers.importStep(out)
|
|
comp = cq.Compound.makeCompound(list(parts.vals()))
|
|
bb = comp.BoundingBox()
|
|
across_flats = max(bb.xlen, bb.ylen) * math.sqrt(3) / 2
|
|
assert abs(across_flats - s_nom) / s_nom < 0.05, (
|
|
f"{size}: across-flats={across_flats:.2f} vs S={s_nom}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("size", list(NUT_SIZES))
|
|
def test_hex_nut_head_across_flats_matches_s(size):
|
|
"""Шестигранная гайка: across-flats из STEP == S (GOST). Guard regression."""
|
|
import math
|
|
|
|
import cadquery as cq
|
|
spec = get_nut(size)
|
|
from fastengen.standard import NUT_SIZES
|
|
s_nom = NUT_SIZES[spec.size]["S"]
|
|
with tempfile.TemporaryDirectory() as d:
|
|
out = os.path.join(d, "h.step")
|
|
generate_nut(spec, out)
|
|
parts = cq.importers.importStep(out)
|
|
comp = cq.Compound.makeCompound(list(parts.vals()))
|
|
bb = comp.BoundingBox()
|
|
across_flats = max(bb.xlen, bb.ylen) * math.sqrt(3) / 2
|
|
assert abs(across_flats - s_nom) / s_nom < 0.05, (
|
|
f"{size}: across-flats={across_flats:.2f} vs S={s_nom}"
|
|
)
|