feat(understand): the snapshot, the vocabulary and the label parsers; MAX_DXF_BYTES 512 MB; the office extra
Track H's foundation, landed on main before its three groups branch. - engineering/understand/snapshot.py: one bulk read of a drawing into frozen WCS records (OCS translated through backends/ocs.py, text decoded, an INSERT boxed by its block geometry without its attribute texts). A DXF is read with ezdxf after the size check; the headless engine's document is read under its own lock; the live engine exports the active document with Document.Export(<tmp>/snap, "DXF", <set of all>) - never renamed, deleted afterwards - and a DWG is opened read-only, exported and closed. Measured on AutoCAD 2026: ".dxf" is refused with Invalid argument, the file lands as snap.DXF, model space, paper space and unused block definitions all reach it, and the selection set does not narrow a DXF export. - engineering/understand/labels.py: %%c, \U+2205, the U+2300 sign, o-stroke and MTEXT format runs all read as one diameter spelling; sizes are kept as drawn and never equated; Cyrillic look-alike capitals fold into Latin tags; panels, wiring callouts and electrical data (kW, phases, volts, N, VFD) in EN / TR / RU / NL / DE. A power that is not written stays None. - engineering/understand/vocab.py: layer classification with a confidence and the deciding keyword, supply / return words, room labels and block kinds in five languages. A CIP layer without a direction word is not guessed. - MAX_DXF_BYTES defaults to 512 MB (a real layout was 188 MB); the refusal names the size and the variable. New `office` extra (openpyxl), also in `full`; uv.lock regenerated. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f0c50d772f
commit
9ab0ae1773
@@ -0,0 +1,30 @@
|
||||
"""The `office` extra: openpyxl for the XLSX workbooks, and in `full` too.
|
||||
|
||||
Declared and locked is what is tested - not installed: the CI lane that
|
||||
installs `[pdf]` has no openpyxl and must stay green, because every XLSX
|
||||
writer refuses with the `xlsx_write` capability and writes CSV instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _extras() -> dict:
|
||||
with open(ROOT / "pyproject.toml", "rb") as handle:
|
||||
return tomllib.load(handle)["project"]["optional-dependencies"]
|
||||
|
||||
|
||||
def test_the_office_extra_carries_openpyxl_and_full_carries_it_too():
|
||||
extras = _extras()
|
||||
assert extras["office"] == ["openpyxl>=3.1"]
|
||||
assert "openpyxl>=3.1" in extras["full"]
|
||||
|
||||
|
||||
def test_the_lock_records_openpyxl_for_the_office_extra():
|
||||
lock = (ROOT / "uv.lock").read_text(encoding="utf-8")
|
||||
assert '\nname = "openpyxl"\n' in lock
|
||||
assert '{ name = "openpyxl" }' in lock
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Label parsers: every spelling a foreign drawing uses, read one way.
|
||||
|
||||
Review Focus 1 (AutoCAD text codes) and 2 (Cyrillic look-alike tags) of the
|
||||
track H plan live here: one test per spelling, because a parser that handles
|
||||
four of five codes fails silently on the fifth drawing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.understand.labels import (
|
||||
CYRILLIC_LOOKALIKES,
|
||||
normalize_panel,
|
||||
parse_diameters,
|
||||
parse_electrical,
|
||||
parse_tag,
|
||||
plain,
|
||||
wiring_target,
|
||||
)
|
||||
|
||||
# -- plain(): AutoCAD codes and MTEXT format runs -------------------------------
|
||||
|
||||
|
||||
def test_plain_decodes_the_percent_codes():
|
||||
assert plain("%%c51") == "\u00d851"
|
||||
assert plain("90%%d") == "90\u00b0"
|
||||
assert plain("%%p0.1") == "\u00b10.1"
|
||||
assert plain("50%%%") == "50%"
|
||||
assert plain("%%uUNDER%%u") == "UNDER"
|
||||
assert plain("%%065") == "A"
|
||||
|
||||
|
||||
def test_plain_decodes_a_unicode_escape():
|
||||
assert plain("\\U+2205 51") == "\u2205 51"
|
||||
assert plain("\\u+00D8") == "\u00d8"
|
||||
|
||||
|
||||
def test_plain_drops_mtext_format_runs_and_keeps_the_content():
|
||||
assert plain("{\\fArial|b0|i0|c0|p34;\u00d851}") == "\u00d851"
|
||||
assert plain("\\H2.5;\\C1;SMS51") == "SMS51"
|
||||
assert plain("\\A1;\\W0.8;\\Q15;\\T1.1;DN20") == "DN20"
|
||||
assert plain("\\LUNDERLINED\\l") == "UNDERLINED"
|
||||
|
||||
|
||||
def test_plain_turns_paragraphs_spaces_and_stacks_into_text():
|
||||
assert plain("PUMP\\PM11") == "PUMP\nM11"
|
||||
assert plain("a\\~b") == "a b"
|
||||
assert plain("\\S1/2;") == "1/2"
|
||||
assert plain("\\S+0.1^-0.2;") == "+0.1/-0.2"
|
||||
|
||||
|
||||
def test_plain_keeps_escaped_backslashes_and_braces():
|
||||
assert plain("\\\\P") == "\\P"
|
||||
assert plain("\\{x\\}") == "{x}"
|
||||
|
||||
|
||||
def test_plain_of_nothing_is_empty():
|
||||
assert plain(None) == ""
|
||||
assert plain("") == ""
|
||||
|
||||
|
||||
# -- parse_diameters(): Review Focus 1, one spelling per test --------------------
|
||||
|
||||
|
||||
def test_the_autocad_percent_code_is_a_diameter():
|
||||
assert parse_diameters("%%c51") == ("\u00d851",)
|
||||
|
||||
|
||||
def test_a_unicode_escaped_diameter_sign_is_a_diameter():
|
||||
assert parse_diameters("\\U+2205 51") == ("\u00d851",)
|
||||
assert parse_diameters("\\U+220551") == ("\u00d851",)
|
||||
|
||||
|
||||
def test_the_diameter_sign_u2300_is_a_diameter():
|
||||
assert parse_diameters("\u230051") == ("\u00d851",)
|
||||
|
||||
|
||||
def test_a_lowercase_o_stroke_is_a_diameter():
|
||||
assert parse_diameters("\u00f851") == ("\u00d851",)
|
||||
|
||||
|
||||
def test_a_diameter_inside_an_mtext_format_run_is_a_diameter():
|
||||
assert parse_diameters("{\\fArial|b0|i0|c0|p34;\u00d851}") == ("\u00d851",)
|
||||
assert parse_diameters("{\\fArial|b0;\\U+2205}51") == ("\u00d851",)
|
||||
|
||||
|
||||
def test_a_diameter_after_a_paragraph_break_is_found():
|
||||
assert parse_diameters("PRODUCT\\P%%c51") == ("\u00d851",)
|
||||
|
||||
|
||||
def test_labels_are_kept_as_drawn_and_never_equated():
|
||||
assert parse_diameters("\u00d825,4") == ("\u00d825,4",)
|
||||
assert parse_diameters("\u00d825") == ("\u00d825",)
|
||||
assert parse_diameters("SMS 51") == ("SMS51",)
|
||||
assert parse_diameters("dn 20") == ("DN20",)
|
||||
assert parse_diameters("DN-25") == ("DN25",)
|
||||
assert parse_diameters("20 x 27") == ("20x27",)
|
||||
assert parse_diameters("20\u044527") == ("20x27",) # Cyrillic х
|
||||
assert parse_diameters('1,5"') == ('1,5"',)
|
||||
assert parse_diameters("1/2\u2033") == ('1/2"',)
|
||||
|
||||
|
||||
def test_several_labels_in_one_text_come_in_order_and_once():
|
||||
assert parse_diameters("\u00d851 / SMS51 / \u00d851") == ("\u00d851", "SMS51")
|
||||
|
||||
|
||||
def test_a_cable_cross_section_is_not_a_pipe_size():
|
||||
assert parse_diameters("5x4 mm\u00b2") == ()
|
||||
assert parse_diameters("3x2,5 mm2") == ()
|
||||
|
||||
|
||||
def test_text_without_a_size_yields_nothing():
|
||||
assert parse_diameters("CIP SUPPLY") == ()
|
||||
assert parse_diameters("T101") == ()
|
||||
|
||||
|
||||
# -- parse_tag(): Review Focus 2, one look-alike per test -------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("cyrillic", "latin"), sorted(CYRILLIC_LOOKALIKES.items()))
|
||||
def test_every_cyrillic_lookalike_folds_to_its_latin_twin(cyrillic, latin):
|
||||
assert parse_tag(f"{cyrillic}101") == f"{latin}101"
|
||||
|
||||
|
||||
def test_a_cyrillic_t_tag_matches_the_latin_one():
|
||||
assert parse_tag("\u04224100") == "T4100"
|
||||
|
||||
|
||||
def test_a_cyrillic_m_tag_matches_the_latin_one():
|
||||
assert parse_tag("\u041c82") == "M82"
|
||||
|
||||
|
||||
def test_a_tag_with_a_cyrillic_prefix_and_a_hyphen():
|
||||
assert parse_tag("\u0421\u0420-1") == "CP-1" # С Р typed in Cyrillic
|
||||
|
||||
|
||||
def test_a_russian_word_is_never_folded_into_a_tag():
|
||||
assert parse_tag("\u041f\u041e\u0414\u0410\u0427\u0410") is None # ПОДАЧА
|
||||
assert parse_tag("\u041f\u041e\u04171") is None # ПОЗ1: П has no Latin twin
|
||||
|
||||
|
||||
def test_tag_shapes():
|
||||
assert parse_tag("T4100") == "T4100"
|
||||
assert parse_tag("pump m41a") == "M41A"
|
||||
assert parse_tag("M47.1") == "M47.1"
|
||||
assert parse_tag("CP-1") == "CP-1"
|
||||
assert parse_tag("CP-M82") == "CP-M82"
|
||||
assert parse_tag("JB-2.") == "JB-2"
|
||||
assert parse_tag("CIP TANK T101") == "T101"
|
||||
|
||||
|
||||
def test_sizes_ratings_and_ip_codes_are_not_tags():
|
||||
assert parse_tag("DN20") is None
|
||||
assert parse_tag("SMS51") is None
|
||||
assert parse_tag("PN16") is None
|
||||
assert parse_tag("IP65") is None
|
||||
assert parse_tag("DN20 T101") == "T101"
|
||||
|
||||
|
||||
def test_text_without_a_tag():
|
||||
assert parse_tag("ROOM 1 PROCESS HALL") is None
|
||||
assert parse_tag(None) is None
|
||||
|
||||
|
||||
# -- panels and wiring callouts ----------------------------------------------------
|
||||
|
||||
|
||||
def test_panel_spellings_become_one():
|
||||
assert normalize_panel("CP 1") == "CP-1"
|
||||
assert normalize_panel("CP1") == "CP-1"
|
||||
assert normalize_panel("cp-1") == "CP-1"
|
||||
assert normalize_panel("CP M82") == "CP-M82"
|
||||
assert normalize_panel("\u0421\u0420 2") == "CP-2"
|
||||
assert normalize_panel("PANEL") is None
|
||||
assert normalize_panel("") is None
|
||||
|
||||
|
||||
def test_wiring_callouts_in_four_languages():
|
||||
assert wiring_target("Wiring to CP1") == "CP-1"
|
||||
assert wiring_target("WIRING TO CP 2") == "CP-2"
|
||||
assert wiring_target("wiring to the CP-M82") == "CP-M82"
|
||||
# подключение к шкафу CP-1
|
||||
assert (
|
||||
wiring_target(
|
||||
"\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 "
|
||||
"\u043a \u0448\u043a\u0430\u0444\u0443 CP-1"
|
||||
)
|
||||
== "CP-1"
|
||||
)
|
||||
assert wiring_target("bedrading naar CP3") == "CP-3"
|
||||
assert wiring_target("CP 2 panosuna") == "CP-2"
|
||||
|
||||
|
||||
def test_a_callout_without_a_wiring_phrase_names_no_panel():
|
||||
assert wiring_target("CP1") is None
|
||||
assert wiring_target("Wiring to") is None
|
||||
|
||||
|
||||
# -- electrical data ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_russian_electrical_text():
|
||||
# P = 18,5 кВт, 3 ф. 400 В + N + PE, ЧРП
|
||||
text = "P = 18,5 \u043a\u0412\u0442, 3 \u0444. 400 \u0412 + N + PE, \u0427\u0420\u041f"
|
||||
assert parse_electrical(text) == {
|
||||
"kw": 18.5,
|
||||
"phases": 3,
|
||||
"voltage": 400,
|
||||
"neutral": True,
|
||||
"vfd": True,
|
||||
}
|
||||
|
||||
|
||||
def test_english_and_turkish_electrical_text():
|
||||
assert parse_electrical("5.5 kW 3ph 400V VFD") == {
|
||||
"kw": 5.5,
|
||||
"phases": 3,
|
||||
"voltage": 400,
|
||||
"neutral": False,
|
||||
"vfd": True,
|
||||
}
|
||||
assert parse_electrical("2,2 kW 3 faz 400 V frekans konvert\u00f6r\u00fc")["vfd"] is True
|
||||
assert parse_electrical("3/N/PE ~ 400 V")["neutral"] is True
|
||||
assert parse_electrical("24 VDC")["voltage"] == 24
|
||||
|
||||
|
||||
def test_a_power_that_is_not_written_is_never_invented():
|
||||
# Нагреватель, 3 ф. 400 В
|
||||
heater = parse_electrical(
|
||||
"\u041d\u0430\u0433\u0440\u0435\u0432\u0430\u0442\u0435\u043b\u044c, 3 \u0444. 400 \u0412"
|
||||
)
|
||||
assert heater["kw"] is None
|
||||
assert heater["phases"] == 3 and heater["voltage"] == 400
|
||||
|
||||
|
||||
def test_energy_is_not_power():
|
||||
assert parse_electrical("1200 kWh")["kw"] is None
|
||||
@@ -0,0 +1,326 @@
|
||||
"""The snapshot: one bulk read of a drawing into frozen WCS records.
|
||||
|
||||
Every expected coordinate and length is worked out by hand in the comment
|
||||
beside it. The live engine's export is exercised against a fake document that
|
||||
behaves the way AutoCAD 2026 was measured to behave (the extension must be
|
||||
"DXF", the file lands as snap.DXF, the document is not renamed).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import ezdxf
|
||||
import pytest
|
||||
from ezdxf.math import Vec2
|
||||
|
||||
import backends.com_backend as cb
|
||||
import config
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from backends.com_backend import ComBackend
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
from engineering.understand.snapshot import (
|
||||
EntityRecord,
|
||||
Snapshot,
|
||||
length_of,
|
||||
read_snapshot,
|
||||
records_from_doc,
|
||||
take_snapshot,
|
||||
)
|
||||
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
def _doc():
|
||||
"""A drawing with one entity of each kind the readers use."""
|
||||
doc = ezdxf.new("R2018")
|
||||
doc.header["$INSUNITS"] = 4
|
||||
doc.layers.add("PIPE", color=3, linetype="Continuous")
|
||||
msp = doc.modelspace()
|
||||
msp.add_line((0, 0), (3, 4), dxfattribs={"layer": "PIPE"})
|
||||
# a closed square whose first edge bulges into a semicircle (bulge 1)
|
||||
msp.add_lwpolyline(
|
||||
[(0, 0, 1.0), (10, 0, 0.0), (10, 10, 0.0), (0, 10, 0.0)],
|
||||
format="xyb",
|
||||
close=True,
|
||||
dxfattribs={"layer": "PIPE"},
|
||||
)
|
||||
msp.add_polyline2d([(0, 0), (0, 5), (5, 5)], dxfattribs={"layer": "PIPE"})
|
||||
msp.add_arc((100, 0), 10, 0, 90, dxfattribs={"layer": "PIPE"})
|
||||
# the same quarter arc mirrored: extrusion (0, 0, -1), OCS centre (-200, 0)
|
||||
# is WCS (200, 0), and the arc runs from 90 to 180 degrees in WCS
|
||||
msp.add_arc((-200, 0), 10, 0, 90, dxfattribs={"layer": "PIPE", "extrusion": (0, 0, -1)})
|
||||
msp.add_circle((50, 50), 5)
|
||||
msp.add_text("%%c51", dxfattribs={"insert": (20, 20), "height": 2.5, "layer": "TXT"})
|
||||
msp.add_mtext("{\\fArial|b0;SMS51}\\PDN20", dxfattribs={"insert": (30, 30), "char_height": 3.5})
|
||||
block = doc.blocks.new("TANK_V")
|
||||
block.add_circle((0, 0), 600)
|
||||
block.add_attdef("TAG", (0, 0), dxfattribs={"height": 250})
|
||||
insert = msp.add_blockref("TANK_V", (1000, 2000), dxfattribs={"rotation": 90})
|
||||
insert.add_auto_attribs({"TAG": "Т4100"}) # Cyrillic Т
|
||||
leader = msp.add_multileader_mtext("Standard")
|
||||
leader.set_content("Wiring to CP1", char_height=2.5)
|
||||
leader.add_leader_line(ezdxf.render.mleader.ConnectionSide.left, [Vec2(0, 0)])
|
||||
leader.build(insert=Vec2(50, 20))
|
||||
doc.layouts.get("Layout1").add_line((1, 1), (2, 1))
|
||||
return doc
|
||||
|
||||
|
||||
def _by_type(snap: Snapshot) -> dict[str, list[EntityRecord]]:
|
||||
out: dict[str, list[EntityRecord]] = {}
|
||||
for rec in snap.records:
|
||||
out.setdefault(rec.type, []).append(rec)
|
||||
return out
|
||||
|
||||
|
||||
def test_records_carry_wcs_geometry_plain_text_and_the_space():
|
||||
snap = records_from_doc(_doc(), source="memory")
|
||||
kinds = _by_type(snap)
|
||||
assert snap.source == "memory"
|
||||
assert snap.insunits == 4
|
||||
assert snap.layouts == ("Model", "Layout1")
|
||||
assert snap.layers["PIPE"] == {"color": 3, "linetype": "Continuous"}
|
||||
(line_model,) = [r for r in kinds["LINE"] if r.space == "Model"]
|
||||
(line_paper,) = [r for r in kinds["LINE"] if r.space == "Layout1"]
|
||||
assert line_model.points == ((0.0, 0.0), (3.0, 4.0))
|
||||
assert line_model.layer == "PIPE"
|
||||
assert line_paper.points == ((1.0, 1.0), (2.0, 1.0))
|
||||
(square,) = kinds["LWPOLYLINE"]
|
||||
assert square.closed is True
|
||||
assert square.bulges == (1.0, 0.0, 0.0, 0.0)
|
||||
(poly,) = kinds["POLYLINE"]
|
||||
assert poly.points == ((0.0, 0.0), (0.0, 5.0), (5.0, 5.0)) and poly.closed is False
|
||||
arcs = sorted(kinds["ARC"], key=lambda r: r.points[0][0])
|
||||
assert arcs[0].points == ((100.0, 0.0),) and arcs[0].angles == pytest.approx((0.0, 90.0))
|
||||
assert arcs[1].points[0] == pytest.approx((200.0, 0.0), abs=EPS)
|
||||
assert arcs[1].angles == pytest.approx((90.0, 180.0), abs=EPS)
|
||||
(text,) = kinds["TEXT"]
|
||||
assert text.text == "Ø51" and text.height == 2.5 and text.points == ((20.0, 20.0),)
|
||||
(mtext,) = kinds["MTEXT"]
|
||||
assert mtext.text == "SMS51\nDN20" and mtext.height == 3.5
|
||||
(insert,) = kinds["INSERT"]
|
||||
assert insert.block == "TANK_V" and insert.points == ((1000.0, 2000.0),)
|
||||
assert insert.attribs == (("TAG", "Т4100"),)
|
||||
assert insert.rotation == 90.0
|
||||
assert insert.bbox == pytest.approx((400.0, 1400.0, 1600.0, 2600.0), abs=1e-6)
|
||||
(leader,) = kinds["MULTILEADER"]
|
||||
assert leader.text == "Wiring to CP1"
|
||||
assert leader.points[0] == (0.0, 0.0) # the arrow tip comes first
|
||||
assert leader.points[-1] == (50.0, 20.0) # the text insertion comes last
|
||||
assert snap.extmin is None and snap.extmax is None # never computed
|
||||
|
||||
|
||||
def test_every_record_is_frozen():
|
||||
rec = records_from_doc(_doc(), source="memory").records[0]
|
||||
with pytest.raises(AttributeError):
|
||||
rec.layer = "OTHER"
|
||||
|
||||
|
||||
def test_lengths():
|
||||
kinds = _by_type(records_from_doc(_doc(), source="memory"))
|
||||
(line_model,) = [r for r in kinds["LINE"] if r.space == "Model"]
|
||||
assert length_of(line_model) == pytest.approx(5.0, abs=EPS) # 3-4-5
|
||||
# semicircle of diameter 10 (5 pi) + three straight sides of 10
|
||||
(square,) = kinds["LWPOLYLINE"]
|
||||
assert length_of(square) == pytest.approx(5.0 * math.pi + 30.0, abs=EPS)
|
||||
(poly,) = kinds["POLYLINE"]
|
||||
assert length_of(poly) == pytest.approx(10.0, abs=EPS)
|
||||
for arc in kinds["ARC"]: # a quarter of a radius-10 circle
|
||||
assert length_of(arc) == pytest.approx(5.0 * math.pi, abs=EPS)
|
||||
(circle,) = kinds["CIRCLE"]
|
||||
assert length_of(circle) == pytest.approx(10.0 * math.pi, abs=EPS)
|
||||
assert length_of(kinds["TEXT"][0]) == 0.0
|
||||
|
||||
|
||||
def test_the_size_limit_defaults_to_512_mb(monkeypatch):
|
||||
monkeypatch.delenv("MAX_DXF_BYTES", raising=False)
|
||||
assert config.Settings().max_dxf_bytes == 512 * 1024 * 1024
|
||||
assert config.DEFAULT_MAX_DXF_BYTES == 536_870_912
|
||||
|
||||
|
||||
def test_read_snapshot_reads_a_file(tmp_path):
|
||||
path = tmp_path / "plant.dxf"
|
||||
_doc().saveas(path)
|
||||
snap = read_snapshot(str(path))
|
||||
assert snap.source == str(path)
|
||||
assert len(snap.records) == len(records_from_doc(_doc(), source="x").records)
|
||||
|
||||
|
||||
def test_read_snapshot_refuses_a_file_over_the_limit_naming_size_and_variable(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "big.dxf"
|
||||
_doc().saveas(path)
|
||||
size = path.stat().st_size
|
||||
monkeypatch.setattr(config.settings, "max_dxf_bytes", size - 1)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
read_snapshot(str(path))
|
||||
message = str(excinfo.value)
|
||||
assert "MAX_DXF_BYTES" in message
|
||||
assert f"{size:,} bytes" in message
|
||||
monkeypatch.setattr(config.settings, "max_dxf_bytes", 0) # 0 disables the check
|
||||
assert read_snapshot(str(path)).records
|
||||
|
||||
|
||||
def test_read_snapshot_refuses_missing_and_unreadable_files(tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_snapshot(str(tmp_path / "nope.dxf"))
|
||||
junk = tmp_path / "junk.dxf"
|
||||
junk.write_text("not a dxf", encoding="utf-8")
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
read_snapshot(str(junk))
|
||||
assert "not a readable DXF" in str(excinfo.value)
|
||||
|
||||
|
||||
# -- take_snapshot on the headless engine ---------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_headless_engine_snapshots_its_current_document_without_touching_it():
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
await backend.drawing_new()
|
||||
line = await backend.entity_create_line(0, 0, 10, 0, layer="PIPE")
|
||||
before = len(await backend.entity_list(limit=1000))
|
||||
snap = await take_snapshot(backend)
|
||||
assert snap.source == "memory:untitled"
|
||||
assert [r.handle for r in snap.records if r.type == "LINE"] == [line.handle]
|
||||
assert len(await backend.entity_list(limit=1000)) == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_dxf_path_is_read_directly(tmp_path):
|
||||
path = tmp_path / "plant.dxf"
|
||||
_doc().saveas(path)
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
snap = await take_snapshot(backend, str(path))
|
||||
assert snap.source == str(path) and snap.records
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_dwg_is_refused_headlessly_with_the_dwg_capability(tmp_path):
|
||||
path = tmp_path / "plant.dwg"
|
||||
path.write_bytes(b"AC1032")
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await take_snapshot(backend, str(path))
|
||||
assert excinfo.value.capability == "dwg"
|
||||
assert "DXF" in str(excinfo.value)
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await take_snapshot(backend, str(tmp_path / "missing.dwg"))
|
||||
with pytest.raises(ValueError):
|
||||
await take_snapshot(backend, str(tmp_path / "plant.txt"))
|
||||
|
||||
|
||||
# -- take_snapshot on the live engine (a fake that behaves as measured) ----------
|
||||
|
||||
|
||||
class _Selection:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
def Select(self, mode, *args): # noqa: N802 - ActiveX member name
|
||||
self.log.append(("select", mode))
|
||||
|
||||
def Delete(self): # noqa: N802
|
||||
self.log.append(("delete_selection",))
|
||||
|
||||
|
||||
class _Selections:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
def Add(self, name): # noqa: N802
|
||||
self.log.append(("add_selection", name))
|
||||
return _Selection(self.log)
|
||||
|
||||
|
||||
class _Doc:
|
||||
def __init__(self, name, log):
|
||||
self.Name = name
|
||||
self.log = log
|
||||
self.SelectionSets = _Selections(log)
|
||||
|
||||
def Export(self, base, extension, selection): # noqa: N802
|
||||
if extension != "DXF":
|
||||
raise ValueError("Invalid argument") # what AutoCAD 2026 answers to ".dxf"
|
||||
self.log.append(("export", Path(base).name, extension))
|
||||
_doc().saveas(base + ".DXF") # AutoCAD writes the upper-case extension
|
||||
|
||||
def SaveAs(self, *args): # noqa: N802
|
||||
raise AssertionError("a snapshot must never rename the document")
|
||||
|
||||
def Close(self, save): # noqa: N802
|
||||
self.log.append(("close", save))
|
||||
|
||||
|
||||
class _Documents:
|
||||
def __init__(self, log):
|
||||
self.log = log
|
||||
|
||||
def Open(self, path, read_only): # noqa: N802
|
||||
self.log.append(("open", Path(path).name, read_only))
|
||||
return _Doc(Path(path).name, self.log)
|
||||
|
||||
|
||||
class _App:
|
||||
def __init__(self, log):
|
||||
self.Documents = _Documents(log)
|
||||
|
||||
|
||||
def _live(monkeypatch, log):
|
||||
backend = ComBackend()
|
||||
|
||||
async def run_inline(func, *args, **kwargs): # bypass the STA executor
|
||||
return func(*args, **kwargs)
|
||||
|
||||
async def ensure_state():
|
||||
log.append(("ensure_document_state",))
|
||||
|
||||
backend._run = run_inline
|
||||
backend._ensure_document_state = ensure_state
|
||||
active = _Doc("Drawing1.dwg", log)
|
||||
monkeypatch.setattr(cb, "_acad_app", lambda: _App(log))
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: active)
|
||||
return backend, active
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_live_engine_exports_a_dxf_snapshot_and_deletes_it(monkeypatch, tmp_path):
|
||||
log: list[tuple] = []
|
||||
made: list[str] = []
|
||||
real_mkdtemp = __import__("tempfile").mkdtemp
|
||||
|
||||
def mkdtemp(prefix=""):
|
||||
folder = real_mkdtemp(prefix=prefix, dir=tmp_path)
|
||||
made.append(folder)
|
||||
return folder
|
||||
|
||||
monkeypatch.setattr("engineering.understand.snapshot.tempfile.mkdtemp", mkdtemp)
|
||||
backend, active = _live(monkeypatch, log)
|
||||
snap = await take_snapshot(backend)
|
||||
assert snap.source == "live:Drawing1.dwg"
|
||||
assert active.Name == "Drawing1.dwg"
|
||||
assert ("select", cb._AC_SELECTION_SET_ALL) in log
|
||||
assert ("export", "snap", "DXF") in log
|
||||
assert ("delete_selection",) in log
|
||||
assert {r.type for r in snap.records} >= {"LINE", "INSERT", "MULTILEADER"}
|
||||
assert made and not Path(made[0]).exists(), "the temporary snapshot must be deleted"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_dwg_on_the_live_engine_is_opened_read_only_and_closed_unsaved(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
path = tmp_path / "plant.dwg"
|
||||
path.write_bytes(b"AC1032")
|
||||
log: list[tuple] = []
|
||||
backend, _active = _live(monkeypatch, log)
|
||||
snap = await take_snapshot(backend, str(path))
|
||||
assert snap.source == "live:plant.dwg"
|
||||
assert ("open", "plant.dwg", True) in log
|
||||
assert ("close", False) in log
|
||||
assert log[-1] == ("ensure_document_state",)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""The multilingual vocabulary: layer names, direction words, room labels, block kinds.
|
||||
|
||||
Every layer name below is one a drafter would write; the ones in the synthetic
|
||||
plant pair (tests/fixtures/plant_pair.py) are here too, because that is what the
|
||||
readers above this module are measured on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.understand.vocab import (
|
||||
DISCIPLINES,
|
||||
EQUIPMENT_KINDS,
|
||||
LANGUAGES,
|
||||
SERVICES,
|
||||
classify_layer,
|
||||
equipment_kind,
|
||||
fold,
|
||||
room_label,
|
||||
supply_return,
|
||||
)
|
||||
|
||||
|
||||
def test_the_closed_lists():
|
||||
assert LANGUAGES == ("en", "tr", "ru", "nl", "de")
|
||||
assert SERVICES == (
|
||||
"product",
|
||||
"cip_supply",
|
||||
"cip_return",
|
||||
"glycol_supply",
|
||||
"glycol_return",
|
||||
"steam",
|
||||
"condensate",
|
||||
"cold_water",
|
||||
"hot_water",
|
||||
"ice_water",
|
||||
"compressed_air",
|
||||
"drain",
|
||||
"electrical",
|
||||
"unknown",
|
||||
)
|
||||
assert DISCIPLINES == (
|
||||
"piping",
|
||||
"electrical",
|
||||
"architecture",
|
||||
"equipment",
|
||||
"annotation",
|
||||
"structure",
|
||||
"unknown",
|
||||
)
|
||||
assert EQUIPMENT_KINDS == ("tank", "pump", "valve", "reducer", "motor", "agitator", "panel")
|
||||
|
||||
|
||||
def test_fold_strips_diacritics_and_the_turkish_dotless_i():
|
||||
assert fold("DÖNÜŞ") == "donus"
|
||||
assert fold("GİDİŞ") == "gidis"
|
||||
assert fold("çıkış") == "cikis"
|
||||
assert fold("RückLauf") == "rucklauf"
|
||||
assert fold("ПОДАЧА") == "подача"
|
||||
assert fold(None) == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "service", "keyword", "language"),
|
||||
[
|
||||
("P_product piping", "product", "product", "en"),
|
||||
("P_cipsupplyline", "cip_supply", "cip", "en"),
|
||||
("P_cipreturnline", "cip_return", "cip", "en"),
|
||||
("P_ijswater", "ice_water", "ijswater", "nl"),
|
||||
("CIP ВОЗВРАТ", "cip_return", "cip", "en"),
|
||||
("glikol dönüş", "glycol_return", "glikol", "tr"),
|
||||
("GLYKOL-VORLAUF", "glycol_supply", "glykol", "de"),
|
||||
("buhar hattı", "steam", "buhar", "tr"),
|
||||
("КОНДЕНСАТ", "condensate", "конденсат", "ru"),
|
||||
("P_ICE WATER", "ice_water", "ice water", "en"),
|
||||
("sıcak su", "hot_water", "sicak su", "tr"),
|
||||
("DRUCKLUFT", "compressed_air", "druckluft", "de"),
|
||||
("air_line", "compressed_air", "air", "en"),
|
||||
("RIOOL", "drain", "riool", "nl"),
|
||||
],
|
||||
)
|
||||
def test_service_layers(name, service, keyword, language):
|
||||
row = classify_layer(name)
|
||||
assert row["service"] == service
|
||||
assert row["discipline"] == "piping"
|
||||
assert row["confidence"] == 0.9
|
||||
assert row["keyword"] == keyword
|
||||
assert row["language"] == language
|
||||
|
||||
|
||||
def test_electrical_layers_are_their_own_service():
|
||||
for name in ("E_power", "ELEKTRIK", "ЭЛЕКТРИКА", "kabel"):
|
||||
row = classify_layer(name)
|
||||
assert (row["discipline"], row["service"], row["confidence"]) == (
|
||||
"electrical",
|
||||
"electrical",
|
||||
0.9,
|
||||
), name
|
||||
|
||||
|
||||
def test_a_cip_layer_without_a_direction_is_not_guessed():
|
||||
row = classify_layer("P_CIP")
|
||||
assert row == {
|
||||
"discipline": "piping",
|
||||
"service": "unknown",
|
||||
"confidence": 0.6,
|
||||
"keyword": "cip",
|
||||
"language": "en",
|
||||
}
|
||||
|
||||
|
||||
def test_a_cip_layer_naming_both_directions_is_not_guessed_either():
|
||||
assert classify_layer("CIP supply return")["service"] == "unknown"
|
||||
|
||||
|
||||
def test_a_water_layer_with_no_temperature_is_piping_of_unknown_service():
|
||||
row = classify_layer("P_WATER")
|
||||
assert (row["discipline"], row["service"], row["confidence"]) == ("piping", "unknown", 0.6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "discipline", "language"),
|
||||
[
|
||||
("KLEPPEN", "piping", "nl"),
|
||||
("VANALAR", "piping", "tr"),
|
||||
("WALLS", "architecture", "en"),
|
||||
("MUREN", "architecture", "nl"),
|
||||
("СТЕНЫ", "architecture", "ru"),
|
||||
("EQUIPMENT", "equipment", "en"),
|
||||
("POMPALAR", "equipment", "tr"),
|
||||
("DIM", "annotation", "en"),
|
||||
("ÖLÇÜ", "annotation", "tr"),
|
||||
("KOLONLAR", "structure", "tr"),
|
||||
],
|
||||
)
|
||||
def test_discipline_layers(name, discipline, language):
|
||||
row = classify_layer(name)
|
||||
assert (row["discipline"], row["service"], row["confidence"], row["language"]) == (
|
||||
discipline,
|
||||
"unknown",
|
||||
0.9,
|
||||
language,
|
||||
)
|
||||
|
||||
|
||||
def test_a_short_keyword_must_start_a_word():
|
||||
assert classify_layer("STAIRS")["service"] != "compressed_air"
|
||||
assert classify_layer("CHAIRS")["discipline"] == "unknown"
|
||||
|
||||
|
||||
def test_a_layer_with_no_keyword():
|
||||
assert classify_layer("0") == {
|
||||
"discipline": "unknown",
|
||||
"service": "unknown",
|
||||
"confidence": 0.0,
|
||||
"keyword": None,
|
||||
"language": None,
|
||||
}
|
||||
assert classify_layer("Defpoints")["discipline"] == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "direction"),
|
||||
[
|
||||
("ПОДАЧА", "supply"),
|
||||
("ВОЗВРАТ", "return"),
|
||||
("обратка CIP", "return"), # обратка
|
||||
("BESLEME", "supply"),
|
||||
("DÖNÜŞ", "return"),
|
||||
("donus hatti", "return"),
|
||||
("CIP SUPPLY", "supply"),
|
||||
("return line", "return"),
|
||||
("AANVOER", "supply"),
|
||||
("RETOUR", "return"),
|
||||
("Vorlauf", "supply"),
|
||||
("RÜCKLAUF", "return"),
|
||||
],
|
||||
)
|
||||
def test_supply_and_return_words_in_five_languages(text, direction):
|
||||
assert supply_return(text) == direction
|
||||
|
||||
|
||||
def test_a_text_naming_both_or_neither_direction_is_none():
|
||||
assert supply_return("CIP SUPPLY / RETURN") is None
|
||||
assert supply_return("Ø51") is None
|
||||
assert supply_return(None) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "expected"),
|
||||
[
|
||||
("ROOM 1 PROCESS HALL", {"number": "1", "name": "PROCESS HALL", "language": "en"}),
|
||||
("ODA 2", {"number": "2", "name": None, "language": "tr"}),
|
||||
("MAHAL 1.05 SOĞUK ODA", {"number": "1.05", "name": "SOĞUK ODA", "language": "tr"}),
|
||||
(
|
||||
"ПОМЕЩЕНИЕ 2 МОЙКА",
|
||||
{"number": "2", "name": "МОЙКА", "language": "ru"},
|
||||
),
|
||||
("RUIMTE 3 opslag", {"number": "3", "name": "opslag", "language": "nl"}),
|
||||
("Raum 4", {"number": "4", "name": None, "language": "de"}),
|
||||
],
|
||||
)
|
||||
def test_room_labels_in_five_languages(text, expected):
|
||||
assert room_label(text) == expected
|
||||
|
||||
|
||||
def test_a_text_without_a_room_word_is_not_a_room_label():
|
||||
assert room_label("T101") is None
|
||||
assert room_label("PROCESS HALL") is None
|
||||
assert room_label("") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("block", "kind", "language"),
|
||||
[
|
||||
("KLEP_VLINDER", "valve", "nl"),
|
||||
("VERLOOPSTUK", "reducer", "nl"),
|
||||
("REDUCER_CONC", "reducer", "en"),
|
||||
("TANK_V", "tank", "en"),
|
||||
("POMP", "pump", "nl"),
|
||||
("SCHAKELKAST", "panel", "nl"),
|
||||
("НАСОС", "pump", "ru"),
|
||||
("VANA_KELEBEK", "valve", "tr"),
|
||||
],
|
||||
)
|
||||
def test_block_names_say_their_kind(block, kind, language):
|
||||
row = equipment_kind(block)
|
||||
assert row["kind"] == kind and row["language"] == language
|
||||
|
||||
|
||||
def test_an_anonymous_block_has_no_kind():
|
||||
assert equipment_kind("*U12") is None
|
||||
assert equipment_kind(None) is None
|
||||
Reference in New Issue
Block a user