"""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}" )