"""The standard-parts catalogue: search, designations, block specs, insertion. Geometry is asserted as emitted primitive dicts - counts, layers, coordinates - never as a screenshot. """ from __future__ import annotations import math import pytest from backends.block_specs import validate_attdef_specs, validate_entity_specs from engineering.mech.stdparts import ( FAMILY_STANDARDS, FAMILY_VIEWS, THREAD_MINOR_RATIO, block_spec, catalogue, parse_designation, ) R12 = 2.0 * 18.0 / math.sqrt(3.0) / 2.0 # across-corners radius of an M12 hex head MINOR12 = THREAD_MINOR_RATIO * 12.0 / 2.0 # ISO 6410 minor radius of an M12 thread def test_the_iso6410_minor_radius_is_four_point_eight(): """Pinned as the builder computes it: 0.8 * 12 / 2 is 4.800000000000001 in binary.""" assert MINOR12 == pytest.approx(4.8) def test_the_catalogue_holds_every_transcribed_size(): rows = catalogue() assert len(rows) == 83 # 5 fastener standards x 10 sizes + 33 bearings assert len(catalogue(standard="ISO 15")) == 33 assert len(catalogue("M12")) == 5 assert len(catalogue("6205")) == 1 assert {row["standard"] for row in rows} == set(FAMILY_STANDARDS) def test_a_catalogue_row_carries_what_a_drawing_needs(): (row,) = catalogue("6205") assert row["designation"] == "ISO 15 - 6205" assert row["standard"] == "ISO 15" assert row["family"] == "deep_groove_bearing" assert row["views"] == ["simplified", "detailed"] assert row["length_required"] is False assert row["dims"] == {"d": 25.0, "D": 52.0, "B": 15.0} assert "ISO 15:2017" in row["source"] bolt = next(r for r in catalogue(standard="ISO 4014") if r["size"] == "M12") assert bolt["length_required"] is True assert bolt["dims"]["s"] == 18.0 and bolt["dims"]["k"] == 7.5 def test_catalogue_refuses_an_unknown_standard(): with pytest.raises(ValueError, match="ISO 4014"): catalogue(standard="DIN 931") def test_parse_designation_normalises_and_splits(): assert parse_designation("iso4014 - m12x60") == { "designation": "ISO 4014 - M12x60", "standard": "ISO 4014", "family": "hex_bolt", "size": "M12x60", "thread": "M12", "length": 60.0, } assert parse_designation("6205")["designation"] == "ISO 15 - 6205" assert parse_designation("ISO 4032 - M12")["length"] is None def test_parse_designation_refuses_a_missing_or_stray_length(): with pytest.raises(ValueError, match="nominal length"): parse_designation("ISO 4014 - M12") with pytest.raises(ValueError, match="takes no length"): parse_designation("ISO 4032 - M12x60") with pytest.raises(ValueError, match="catalogue standard"): parse_designation("ISO 9999 - M12") def test_an_unknown_size_is_refused_with_the_nearest_entries(): with pytest.raises(ValueError, match=r"ISO 4014 - M12"): block_spec("ISO 4014 - M13x60", "side") with pytest.raises(ValueError, match=r"ISO 15 - 6210"): block_spec("ISO 15 - 6211", "simplified") with pytest.raises(ValueError, match="approximately"): block_spec("ISO 4014 - M13x60", "side") def test_hex_bolt_side_view_is_the_iso6410_representation(): spec = block_spec("ISO 4014 - M12x60", "side") assert spec["name"] == "STD_ISO_4014_M12X60_SIDE" assert spec["view"] == "side" assert spec["dims"]["b"] == pytest.approx(30.0) # 2d + 6 for l <= 125 ents = spec["entities"] assert len(ents) == 10 assert ents[0]["type"] == "polyline" and ents[0]["closed"] is True assert ents[0]["points"] == [ [-7.5, -R12], [0.0, -R12], [0.0, R12], [-7.5, R12], ] assert ents[3] == { "type": "line", "x1": 0.0, "y1": 6.0, "x2": 60.0, "y2": 6.0, "layer": "GEOMETRY", } # ISO 6410: minor diameter at 0.8 x major, over the useful thread length. assert ents[6] == { "type": "line", "x1": 30.0, "y1": MINOR12, "x2": 60.0, "y2": MINOR12, "layer": "GEOMETRY", } # ISO 6410: the thread-length limit is a line to the major diameter. assert ents[8] == { "type": "line", "x1": 30.0, "y1": -6.0, "x2": 30.0, "y2": 6.0, "layer": "GEOMETRY", } assert ents[9]["layer"] == "CENTER" assert spec["bbox"] == pytest.approx((-10.5, -R12, 63.0, R12)) def test_a_fully_threaded_screw_has_no_thread_limit_line(): spec = block_spec("ISO 4017 - M12x60", "side") assert len(spec["entities"]) == 9 assert spec["dims"]["b"] == pytest.approx(60.0) minor = [e for e in spec["entities"] if e.get("y1") == MINOR12] assert minor[0]["x1"] == 0.0 and minor[0]["x2"] == 60.0 def test_hex_top_view_uses_the_three_quarter_minor_arc(): spec = block_spec("ISO 4014 - M12x60", "top") ents = spec["entities"] assert len(ents) == 5 assert ents[0]["type"] == "polyline" and len(ents[0]["points"]) == 6 assert ents[1] == {"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0, "layer": "GEOMETRY"} assert ents[2] == { "type": "arc", "cx": 0.0, "cy": 0.0, "r": MINOR12, "start_deg": 90.0, "end_deg": 360.0, "layer": "GEOMETRY", } assert ents[3]["layer"] == "CENTER" and ents[4]["layer"] == "CENTER" def test_hex_nut_side_view_shows_the_thread_as_hidden_lines(): spec = block_spec("ISO 4032 - M12", "side") ents = spec["entities"] assert len(ents) == 8 hidden = [e for e in ents if e["layer"] == "HIDDEN"] assert len(hidden) == 4 assert {abs(e["y1"]) for e in hidden} == {6.0, MINOR12} assert ents[0]["points"][1] == [10.8, -R12] # nut height m def test_washer_views(): side = block_spec("ISO 7089 - M12", "side") assert len(side["entities"]) == 3 assert side["entities"][0]["points"] == [[0.0, 6.5], [2.5, 6.5], [2.5, 12.0], [0.0, 12.0]] top = block_spec("ISO 7089 - M12", "top") assert top["entities"][0] == { "type": "circle", "cx": 0.0, "cy": 0.0, "r": 12.0, "layer": "GEOMETRY", } assert top["entities"][1]["r"] == 6.5 def test_socket_head_screw_views(): side = block_spec("ISO 4762 - M8x30", "side") assert len(side["entities"]) == 8 assert side["dims"]["b"] == pytest.approx(28.0) assert side["entities"][0]["points"][0] == [-8.0, -6.5] # k = 8, dk/2 = 6.5 top = block_spec("ISO 4762 - M8x30", "top") assert top["entities"][0]["r"] == 6.5 assert len(top["entities"][1]["points"]) == 6 # the hexagon socket, s = 6 def test_bearing_simplified_is_the_iso8826_1_outline_and_cross(): spec = block_spec("ISO 15 - 6205", "simplified") ents = spec["entities"] assert len(ents) == 7 assert ents[0]["points"] == [[-7.5, 12.5], [7.5, 12.5], [7.5, 26.0], [-7.5, 26.0]] assert ents[1]["points"] == [[-7.5, -12.5], [7.5, -12.5], [7.5, -26.0], [-7.5, -26.0]] assert ents[2] == { "type": "line", "x1": -4.5, "y1": 19.25, "x2": 4.5, "y2": 19.25, "layer": "GEOMETRY", } assert ents[3] == { "type": "line", "x1": 0.0, "y1": 15.2, "x2": 0.0, "y2": 23.3, "layer": "GEOMETRY", } assert ents[6]["layer"] == "CENTER" def test_bearing_detailed_draws_the_element_symbol(): spec = block_spec("ISO 15 - 6205", "detailed") ents = spec["entities"] assert len(ents) == 5 assert ents[2] == { "type": "circle", "cx": 0.0, "cy": 19.25, "r": 4.05, "layer": "GEOMETRY", } assert ents[3]["cy"] == -19.25 def test_a_view_the_family_does_not_have_is_refused_and_side_falls_back(): assert block_spec("ISO 15 - 6205", "side")["view"] == "simplified" with pytest.raises(ValueError, match="simplified"): block_spec("ISO 15 - 6205", "top") with pytest.raises(ValueError, match="side"): block_spec("ISO 4014 - M12x60", "simplified") @pytest.mark.parametrize( ("designation", "view"), [ ("ISO 4014 - M12x60", "side"), ("ISO 4014 - M12x60", "top"), ("ISO 4017 - M12x60", "side"), ("ISO 4032 - M12", "side"), ("ISO 4032 - M12", "top"), ("ISO 7089 - M12", "side"), ("ISO 7089 - M12", "top"), ("ISO 4762 - M8x30", "side"), ("ISO 4762 - M8x30", "top"), ("ISO 15 - 6205", "simplified"), ("ISO 15 - 6205", "detailed"), ], ) def test_every_block_spec_validates_against_block_define(designation, view): spec = block_spec(designation, view) validate_entity_specs(spec["entities"]) validate_attdef_specs(spec["attdefs"]) assert [att["tag"] for att in spec["attdefs"]] == ["DESIG", "STD", "SIZE", "MAT"] assert spec["attdefs"][0]["default"] == spec["designation"] assert spec["attdefs"][0]["invisible"] is False assert all(att["invisible"] for att in spec["attdefs"][1:]) assert spec["bbox"][0] < spec["bbox"][2] and spec["bbox"][1] < spec["bbox"][3] assert set(FAMILY_VIEWS[spec["family"]]) >= {spec["view"]} @pytest.mark.asyncio async def test_insert_defines_the_block_once_and_writes_the_payload(backend): from engineering.mech.stdparts import insert_std_part from engineering.mech.xdata import APP_ID, decode result = await insert_std_part(backend, "ISO 4014 - M12x60", at=(100.0, 50.0), material="8.8") assert result["ok"] is True assert result["block_name"] == "STD_ISO_4014_M12X60_SIDE" assert result["defined"] is True assert sorted(result["layers_created"]) == ["CENTER", "GEOMETRY"] assert result["view"] == "side" assert result["layer"] == "GEOMETRY" assert result["dims"]["s"] == 18.0 stored = await backend.entity_get_xdata(result["handle"], APP_ID) payload = decode([(1001, APP_ID)] + [(1000, value) for value in stored["xdata"][APP_ID]]) assert payload == { "v": 1, "kind": "std_part", "designation": "ISO 4014 - M12x60", "standard": "ISO 4014", "size": "M12x60", "qty": 1, "material": "8.8", } assert await backend.block_get_attributes(result["handle"]) == { "DESIG": "ISO 4014 - M12x60", "STD": "ISO 4014", "SIZE": "M12x60", "MAT": "8.8", } again = await insert_std_part(backend, "ISO 4014 - M12x60", at=(200.0, 50.0)) assert again["defined"] is False and again["layers_created"] == [] assert again["payload"]["material"] == "" @pytest.mark.asyncio async def test_bearing_inserts_with_its_own_view_name(backend): from engineering.mech.stdparts import insert_std_part result = await insert_std_part(backend, "6205", at=(0.0, 0.0), rotation=90.0) assert result["block_name"] == "STD_ISO_15_6205_SIMPLIFIED" assert result["view"] == "simplified" assert result["rotation"] == 90.0 assert result["dims"]["B"] == 15.0 @pytest.mark.asyncio async def test_insert_refuses_before_writing_anything(backend): from engineering.mech.stdparts import insert_std_part before = len(await backend.entity_list()) with pytest.raises(ValueError, match="Nearest entries"): await insert_std_part(backend, "ISO 4014 - M13x60", at=(0.0, 0.0)) with pytest.raises(ValueError, match="finite"): await insert_std_part(backend, "ISO 4014 - M12x60", at=(float("nan"), 0.0)) with pytest.raises(ValueError, match="no attribute"): await insert_std_part( backend, "ISO 4014 - M12x60", at=(0.0, 0.0), attributes={"TORQUE": "80"} ) assert len(await backend.entity_list()) == before