feat(arch): the plan model and its foundation - layers, materials, EN/TR, two generalisations
Track F starts here; the three group branches are cut from this commit. - engineering/arch/model.py: Wall, Opening, Stair and Room as frozen dataclasses, and every refusal of spec 4 before anything is drawn, by path (walls[2].thickness): finite positive numbers, an axis of at least two distinct points with no zero-length segment, an arc segment refused by name rather than approximated, an opening on an unknown wall, past its wall, across an axis vertex or overlapping another (both ids named), an unknown material, and a typed room area - areas are measured, never typed. The plan travels as ACADMCP_ARCH XDATA; an opening's door/window kind and a stair's straight/l/u kind ride beside the payload kind as opening_kind / stair_kind, because one key cannot hold both. - engineering/arch/layers.py: the arch set - thirteen ISO 13567-style layers with ISO 128 lineweights only (0.05 mm on the construction scratch layer, as every set in this repository) - and ARCH_ROLE_LAYER, the one place a role becomes a layer. Registered in engineering/layers.py last, which is what keeps the engineering.layers <-> engineering.arch import cycle closed. - engineering/arch/materials.py: eight wall materials, one pattern each, every row citing its source: ANSI31 for brick (ISO 128-50 general hatching, acadiso.pat's own "Iron, Brick, Stone masonry"), AAC as ANSI31 at twice the spacing (a repository convention, and it says so), AR-CONC / AR-SAND as AutoCAD patterns, and the JIS A 0150 RC / stone / wood symbols. Reinforced concrete cannot be ANSI31 and AR-CONC in one HATCH, so it takes the one symbol that names it. Periods on a 1:50 sheet are recomputed from ezdxf's own pattern definitions by the tests. normalise_material is the one reading of a material name, shared by the model and wall_hatch, so the model never refuses "Reinforced Concrete" that the hatch table resolves. - engineering/arch/lang.py: EN / TR vocabulary, D/W and K/P tag prefixes, and the decimal comma for lang="tr" (24,50 m2). - draw_prims(role_layer=) and the XDATA codec's app_id= / kinds=: both generalised with the mechanical defaults, and the mechanical suite passes unchanged against them - that is the regression gate. 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
b012fdf1be
commit
d5c5e90406
@@ -0,0 +1,367 @@
|
||||
"""Track F's shared foundation: the arch layer set, the wall-material hatches,
|
||||
EN/TR vocabulary, and the two generalisations of the mechanical layer
|
||||
(`draw_prims(role_layer=)` and the XDATA codec's `app_id=` / `kinds=`).
|
||||
|
||||
The mechanical callers are the regression gate for the two generalisations:
|
||||
`tests/test_mech_draw.py`, `tests/test_mech_xdata.py` and the rest of the
|
||||
mechanical suite run unchanged against the new signatures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
from ezdxf.tools import pattern as ezpattern
|
||||
|
||||
from engineering.arch.lang import LANGS, fmt_area_m2, fmt_number, tag_prefix, vocab
|
||||
from engineering.arch.layers import ARCH_LAYERS, ARCH_ROLE_LAYER
|
||||
from engineering.arch.materials import WALL_HATCH_TABLE, WALL_MATERIALS, wall_hatch
|
||||
from engineering.layers import LAYER_ROLES, LAYER_SET_REGISTRY, apply_layer_set, resolve_role_layer
|
||||
from engineering.mech import xdata as codec
|
||||
from engineering.mech.draw import draw_prims
|
||||
from engineering.mech.primitives import HatchArea, Line, Text
|
||||
from engineering.plan_spec import ISO_128_LINEWEIGHTS_MM
|
||||
|
||||
# -- the layer set ---------------------------------------------------------------
|
||||
|
||||
EXPECTED_LAYERS = {
|
||||
"A-WALL-E-N": ("Continuous", 0.50),
|
||||
"A-WALL-H-N": ("Continuous", 0.13),
|
||||
"A-DOOR-E-N": ("Continuous", 0.25),
|
||||
"A-WIND-E-N": ("Continuous", 0.25),
|
||||
"A-STAIR-E-N": ("Continuous", 0.25),
|
||||
"A-FURN-E-N": ("Continuous", 0.18),
|
||||
"A-SANR-E-N": ("Continuous", 0.18),
|
||||
"A-GRID-E-N": ("CENTER", 0.18),
|
||||
"A-ROOM-T-N": ("Continuous", 0.25),
|
||||
"A-DIMEN-T-N": ("Continuous", 0.18),
|
||||
"A-SYMB-T-N": ("Continuous", 0.25),
|
||||
"A-OVHD-E-N": ("HIDDEN", 0.18),
|
||||
"A-CONST-E-N": ("Continuous", 0.05),
|
||||
}
|
||||
|
||||
|
||||
def test_the_arch_set_is_the_thirteen_pinned_layers_plus_zero():
|
||||
rows = {name: (linetype, lw) for name, _aci, linetype, lw, _d in ARCH_LAYERS}
|
||||
assert rows.pop("0") == ("Continuous", 0.25)
|
||||
assert rows == EXPECTED_LAYERS
|
||||
|
||||
|
||||
def test_every_lineweight_but_the_construction_scratch_is_iso_128():
|
||||
for name, _aci, _lt, lineweight, _d in ARCH_LAYERS:
|
||||
if "CONST" in name:
|
||||
assert lineweight == 0.05 # skipped by the iso128 critique, cleared before finalize
|
||||
continue
|
||||
assert lineweight in ISO_128_LINEWEIGHTS_MM, name
|
||||
|
||||
|
||||
def test_the_construction_layer_is_colour_250_like_every_other_set():
|
||||
colours = {name: aci for name, aci, _lt, _lw, _d in ARCH_LAYERS}
|
||||
assert colours["A-CONST-E-N"] == 250
|
||||
|
||||
|
||||
def test_every_role_maps_onto_a_layer_of_the_set():
|
||||
names = {row[0] for row in ARCH_LAYERS}
|
||||
assert set(ARCH_ROLE_LAYER.values()) <= names
|
||||
assert ARCH_ROLE_LAYER == {
|
||||
"wall": "A-WALL-E-N",
|
||||
"wall_hatch": "A-WALL-H-N",
|
||||
"door": "A-DOOR-E-N",
|
||||
"window": "A-WIND-E-N",
|
||||
"stair": "A-STAIR-E-N",
|
||||
"furniture": "A-FURN-E-N",
|
||||
"sanitary": "A-SANR-E-N",
|
||||
"grid": "A-GRID-E-N",
|
||||
"room": "A-ROOM-T-N",
|
||||
"dim": "A-DIMEN-T-N",
|
||||
"symbol": "A-SYMB-T-N",
|
||||
"overhead": "A-OVHD-E-N",
|
||||
}
|
||||
|
||||
|
||||
def test_the_set_is_registered_with_its_dimension_and_construction_roles():
|
||||
assert LAYER_SET_REGISTRY["arch"] is ARCH_LAYERS
|
||||
assert LAYER_ROLES["arch"] == {"dim": "A-DIMEN-T-N", "construction": "A-CONST-E-N"}
|
||||
assert resolve_role_layer("arch", "dim") == "A-DIMEN-T-N"
|
||||
assert resolve_role_layer("arch", "construction") == "A-CONST-E-N"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_layer_set_arch_creates_every_layer_with_its_row(backend):
|
||||
result = await apply_layer_set(backend, "arch")
|
||||
assert all(result[name] == "created" for name in EXPECTED_LAYERS)
|
||||
layers = {lyr.name: lyr for lyr in await backend.layer_list()}
|
||||
assert layers["A-GRID-E-N"].linetype.upper() == "CENTER"
|
||||
assert layers["A-OVHD-E-N"].linetype.upper() == "HIDDEN"
|
||||
assert layers["A-CONST-E-N"].color == 250
|
||||
|
||||
|
||||
# -- wall materials --------------------------------------------------------------
|
||||
|
||||
|
||||
def _period(name: str) -> float:
|
||||
"""The finest perpendicular line period of a pattern at scale 1 (ezdxf ISO set)."""
|
||||
periods = []
|
||||
for angle, _base, offset, _dashes in ezpattern.load(measurement=1)[name]:
|
||||
rad = math.radians(angle)
|
||||
period = abs(-offset[0] * math.sin(rad) + offset[1] * math.cos(rad))
|
||||
if period > 1e-9:
|
||||
periods.append(period)
|
||||
return min(periods)
|
||||
|
||||
|
||||
def test_the_eight_pinned_materials_each_have_one_row():
|
||||
assert WALL_MATERIALS == (
|
||||
"brick",
|
||||
"aac",
|
||||
"concrete",
|
||||
"reinforced_concrete",
|
||||
"gypsum_board",
|
||||
"stone",
|
||||
"timber",
|
||||
"generic",
|
||||
)
|
||||
assert set(WALL_HATCH_TABLE) == set(WALL_MATERIALS)
|
||||
|
||||
|
||||
def test_wall_hatch_returns_exactly_pattern_angle_scale():
|
||||
assert wall_hatch("brick") == {"pattern": "ANSI31", "angle": 0.0, "scale": 20.0}
|
||||
assert wall_hatch("Reinforced Concrete")["pattern"] == "JIS_RC_15"
|
||||
|
||||
|
||||
def test_an_unknown_material_is_refused_with_the_list():
|
||||
with pytest.raises(
|
||||
ValueError, match=r"unknown wall material 'adobe'; wall materials are brick, aac"
|
||||
):
|
||||
wall_hatch("adobe")
|
||||
|
||||
|
||||
def test_every_pattern_exists_in_both_ezdxf_measurement_sets():
|
||||
"""ezdxf silently falls back to ANSI31 for a pattern it does not know."""
|
||||
iso = ezpattern.load(measurement=1)
|
||||
imperial = ezpattern.load(measurement=0)
|
||||
for material in WALL_MATERIALS:
|
||||
name = wall_hatch(material)["pattern"]
|
||||
assert name in iso and name in imperial, material
|
||||
|
||||
|
||||
def test_the_periods_on_a_1_to_50_sheet_are_the_documented_ones():
|
||||
"""Recomputed from ezdxf's own definitions: period x scale / 50 = paper mm."""
|
||||
expected_paper_mm = {
|
||||
"brick": 1.27,
|
||||
"aac": 2.54,
|
||||
"reinforced_concrete": 3.0,
|
||||
"stone": 1.5,
|
||||
"timber": 1.273,
|
||||
"generic": 1.27,
|
||||
}
|
||||
for material, paper in expected_paper_mm.items():
|
||||
row = wall_hatch(material)
|
||||
assert round(_period(row["pattern"]) * row["scale"] / 50.0, 3) == paper, material
|
||||
|
||||
|
||||
def test_aac_is_the_lighter_diagonal_of_brick():
|
||||
assert wall_hatch("aac")["pattern"] == wall_hatch("brick")["pattern"]
|
||||
assert wall_hatch("aac")["scale"] == 2 * wall_hatch("brick")["scale"]
|
||||
|
||||
|
||||
def test_every_row_states_where_its_symbol_comes_from():
|
||||
for material in WALL_MATERIALS:
|
||||
source = WALL_HATCH_TABLE[material]["source"]
|
||||
assert any(
|
||||
token in source for token in ("ISO 128-50", "JIS A 0150", "acadiso.pat", "convention")
|
||||
)
|
||||
|
||||
|
||||
# -- language --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_both_languages_carry_the_same_keys():
|
||||
assert LANGS == ("en", "tr")
|
||||
assert (
|
||||
set(vocab("en"))
|
||||
== set(vocab("tr"))
|
||||
== {
|
||||
"door_schedule",
|
||||
"window_schedule",
|
||||
"room_schedule",
|
||||
"tag",
|
||||
"width",
|
||||
"height",
|
||||
"sill",
|
||||
"swing",
|
||||
"hand",
|
||||
"wall",
|
||||
"number",
|
||||
"name",
|
||||
"area",
|
||||
"total",
|
||||
"up",
|
||||
"down",
|
||||
"north",
|
||||
"level",
|
||||
"in",
|
||||
"out",
|
||||
"left",
|
||||
"right",
|
||||
}
|
||||
)
|
||||
assert vocab("tr")["up"] == "ÇIKIŞ"
|
||||
assert vocab("en")["up"] == "UP"
|
||||
|
||||
|
||||
def test_tag_prefixes():
|
||||
assert (tag_prefix("door", "en"), tag_prefix("window", "en")) == ("D", "W")
|
||||
assert (tag_prefix("door", "tr"), tag_prefix("window", "tr")) == ("K", "P")
|
||||
|
||||
|
||||
def test_turkish_switches_the_decimal_separator_to_a_comma():
|
||||
assert fmt_number(24.5, "en") == "24.50"
|
||||
assert fmt_number(24.5, "tr") == "24,50"
|
||||
assert fmt_number(-0.001, "en") == "0.00"
|
||||
assert fmt_area_m2(24_500_000.0, "en") == "24.50 m²"
|
||||
assert fmt_area_m2(24_500_000.0, "tr") == "24,50 m²"
|
||||
assert fmt_area_m2(20_000_000.0, "en") == "20.00 m²"
|
||||
|
||||
|
||||
def test_an_unknown_language_is_refused_with_the_list():
|
||||
for call in (
|
||||
lambda: vocab("de"),
|
||||
lambda: fmt_number(1.0, "de"),
|
||||
lambda: tag_prefix("door", "fr"),
|
||||
):
|
||||
with pytest.raises(
|
||||
ValueError, match=r"lang: unknown language '(de|fr)'; languages are en, tr"
|
||||
):
|
||||
call()
|
||||
|
||||
|
||||
def test_a_negative_area_is_refused():
|
||||
with pytest.raises(ValueError, match=r"area: must be finite and not negative"):
|
||||
fmt_area_m2(-1.0, "en")
|
||||
|
||||
|
||||
# -- draw_prims(role_layer=) ---------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_role_map_puts_each_role_on_its_architectural_layer(backend):
|
||||
square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0))
|
||||
result = await draw_prims(
|
||||
backend,
|
||||
[
|
||||
Line((0.0, 0.0), (200.0, 0.0), "wall"),
|
||||
Line((0.0, 0.0), (0.0, 900.0), "door"),
|
||||
Text((500.0, 500.0), "LIVING", 125.0, 0.0, "room"),
|
||||
HatchArea((square,), "brick"),
|
||||
],
|
||||
role_layer=ARCH_ROLE_LAYER,
|
||||
)
|
||||
assert len(result["handles"]) == 4
|
||||
placed = {(e.type, e.layer) for e in await backend.entity_list(limit=100)}
|
||||
assert placed == {
|
||||
("LINE", "A-WALL-E-N"),
|
||||
("LINE", "A-DOOR-E-N"),
|
||||
("TEXT", "A-ROOM-T-N"),
|
||||
("HATCH", "A-WALL-H-N"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_wall_hatch_takes_the_wall_material_pattern(backend):
|
||||
square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0))
|
||||
await draw_prims(
|
||||
backend, [HatchArea((square,), "reinforced_concrete")], role_layer=ARCH_ROLE_LAYER
|
||||
)
|
||||
(hatch,) = await backend.entity_list(type_filter="HATCH", limit=10)
|
||||
entity = backend._doc.entitydb[hatch.handle]
|
||||
assert entity.dxf.pattern_name == "JIS_RC_15"
|
||||
assert math.isclose(entity.dxf.pattern_scale, 10.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_architectural_layers_are_created_with_their_row(backend):
|
||||
await draw_prims(backend, [Line((0.0, 0.0), (1000.0, 0.0), "grid")], role_layer=ARCH_ROLE_LAYER)
|
||||
layers = {lyr.name: lyr for lyr in await backend.layer_list()}
|
||||
grid = layers["A-GRID-E-N"]
|
||||
assert grid.linetype.upper() == "CENTER"
|
||||
assert grid.color == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_role_is_refused_before_anything_is_drawn(backend):
|
||||
with pytest.raises(
|
||||
ValueError, match=r"draw_prims: unknown role 'visible'; this role map has wall"
|
||||
):
|
||||
await draw_prims(
|
||||
backend,
|
||||
[Line((0.0, 0.0), (1.0, 0.0), "wall"), Line((0.0, 0.0), (1.0, 0.0), "visible")],
|
||||
role_layer=ARCH_ROLE_LAYER,
|
||||
)
|
||||
assert await backend.entity_list(limit=10) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_wall_material_is_refused_before_anything_is_drawn(backend):
|
||||
square = ((0.0, 0.0), (200.0, 0.0), (200.0, 1000.0), (0.0, 1000.0))
|
||||
with pytest.raises(ValueError, match=r"unknown wall material 'steel'"):
|
||||
await draw_prims(
|
||||
backend,
|
||||
[Line((0.0, 0.0), (1.0, 0.0), "wall"), HatchArea((square,), "steel")],
|
||||
role_layer=ARCH_ROLE_LAYER,
|
||||
)
|
||||
assert await backend.entity_list(limit=10) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_without_a_role_map_the_mechanical_layers_and_materials_are_unchanged(backend):
|
||||
square = ((0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0))
|
||||
await draw_prims(backend, [Line((0.0, 0.0), (1.0, 0.0)), HatchArea((square,), "steel")])
|
||||
layers = {e.layer for e in await backend.entity_list(limit=10)}
|
||||
assert layers == {"GEOMETRY", "HATCH"}
|
||||
(hatch,) = await backend.entity_list(type_filter="HATCH", limit=10)
|
||||
assert backend._doc.entitydb[hatch.handle].dxf.pattern_name == "ANSI31"
|
||||
|
||||
|
||||
# -- the XDATA codec's app_id / kinds ----------------------------------------------
|
||||
|
||||
|
||||
def test_the_codec_defaults_are_still_the_mechanical_ones():
|
||||
part = {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": {}}
|
||||
assert codec.encode(part)[0] == (1001, "ACADMCP_MECH")
|
||||
assert codec.decode(codec.to_values(part)) == part
|
||||
|
||||
|
||||
def test_the_codec_writes_and_reads_another_application():
|
||||
payload = {"v": 1, "kind": "thing", "n": 3}
|
||||
rows = codec.encode(payload, app_id="ACADMCP_TEST", kinds=("thing",))
|
||||
assert rows[0] == (1001, "ACADMCP_TEST")
|
||||
assert codec.decode(rows, app_id="ACADMCP_TEST", kinds=("thing",)) == payload
|
||||
with pytest.raises(
|
||||
ValueError, match=r"ACADMCP_TEST: unknown payload kind 'part'; kinds are thing"
|
||||
):
|
||||
codec.encode({"v": 1, "kind": "part"}, app_id="ACADMCP_TEST", kinds=("thing",))
|
||||
with pytest.raises(
|
||||
ValueError, match=r"ACADMCP_MECH: this XDATA belongs to application 'ACADMCP_TEST'"
|
||||
):
|
||||
codec.decode(rows)
|
||||
|
||||
|
||||
# -- the tool ------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drawing_apply_iso_layers_takes_arch():
|
||||
from fastmcp import Client
|
||||
|
||||
import server
|
||||
|
||||
async with Client(server.mcp) as client:
|
||||
await client.call_tool("drawing_new", {})
|
||||
result = (
|
||||
await client.call_tool("drawing_apply_iso_layers", {"standard": "arch"})
|
||||
).structured_content
|
||||
assert result["standard"] == "arch"
|
||||
assert result["layers"]["A-WALL-E-N"] == "created"
|
||||
assert result["layers"]["A-GRID-E-N"] == "created"
|
||||
@@ -0,0 +1,312 @@
|
||||
"""The plan model: every refusal of spec §4, by path, and the ACADMCP_ARCH round trip.
|
||||
|
||||
Pure: no backend. A refusal is a ``ValueError`` whose message starts with the
|
||||
path of the offending field, so a caller of ``arch_plan_from_spec`` is told
|
||||
``walls[2].thickness`` and not "invalid wall".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.arch.model import (
|
||||
APP_ID,
|
||||
KINDS,
|
||||
Opening,
|
||||
Room,
|
||||
Stair,
|
||||
Wall,
|
||||
decode,
|
||||
encode,
|
||||
from_payload,
|
||||
opening_from_dict,
|
||||
room_from_dict,
|
||||
segment_of,
|
||||
stair_from_dict,
|
||||
to_payload,
|
||||
to_values,
|
||||
validate_openings,
|
||||
wall_from_dict,
|
||||
wall_length,
|
||||
)
|
||||
|
||||
L_WALL = {"id": "W1", "axis": [[0, 0], [5000, 0], [5000, 4000]], "thickness": 200}
|
||||
|
||||
|
||||
def _wall(**over) -> Wall:
|
||||
return wall_from_dict({**L_WALL, **over}, "walls[0]")
|
||||
|
||||
|
||||
def _door(**over) -> Opening:
|
||||
base = {"id": "D1", "wall": "W1", "kind": "door", "offset": 1000, "width": 900}
|
||||
return opening_from_dict({**base, **over}, "openings[0]")
|
||||
|
||||
|
||||
# -- walls -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_wall_reads_its_defaults_and_normalises_its_axis():
|
||||
wall = _wall()
|
||||
assert wall == Wall(
|
||||
id="W1",
|
||||
axis=((0.0, 0.0), (5000.0, 0.0), (5000.0, 4000.0)),
|
||||
thickness=200.0,
|
||||
justification="center",
|
||||
material="brick",
|
||||
closed=False,
|
||||
)
|
||||
assert wall_length(wall) == 9000.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "reason"),
|
||||
[
|
||||
(-200, "must be greater than zero"),
|
||||
(0, "must be greater than zero"),
|
||||
(float("nan"), "must be finite"),
|
||||
(float("inf"), "must be finite"),
|
||||
("thick", "expected a number"),
|
||||
(True, "expected a number"),
|
||||
],
|
||||
)
|
||||
def test_a_thickness_that_is_not_a_finite_positive_number_is_refused_by_path(value, reason):
|
||||
with pytest.raises(ValueError, match=rf"^walls\[2\]\.thickness: {reason}"):
|
||||
wall_from_dict({**L_WALL, "thickness": value}, "walls[2]")
|
||||
|
||||
|
||||
def test_an_axis_needs_two_points():
|
||||
with pytest.raises(
|
||||
ValueError, match=r"^walls\[0\]\.axis: a wall needs at least 2 points, got 1"
|
||||
):
|
||||
_wall(axis=[[0, 0]])
|
||||
|
||||
|
||||
def test_a_closed_axis_needs_three_points():
|
||||
with pytest.raises(ValueError, match=r"^walls\[0\]\.axis: a closed wall needs at least 3"):
|
||||
_wall(axis=[[0, 0], [10, 0]], closed=True)
|
||||
|
||||
|
||||
def test_a_zero_length_segment_is_refused_at_the_repeated_point():
|
||||
with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[2\]: repeats axis\[1\]"):
|
||||
_wall(axis=[[0, 0], [5000, 0], [5000, 0], [5000, 4000]])
|
||||
|
||||
|
||||
def test_a_closed_axis_that_repeats_its_first_point_is_refused():
|
||||
with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[3\]: repeats axis\[0\]"):
|
||||
_wall(axis=[[0, 0], [10, 0], [10, 10], [0, 0]], closed=True)
|
||||
|
||||
|
||||
def test_an_arc_segment_is_refused_by_name_not_approximated():
|
||||
with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[1\]: .*arc segment is refused"):
|
||||
_wall(axis=[[0, 0], [5000, 0, 0.4142], [5000, 4000]])
|
||||
with pytest.raises(ValueError, match=r"^walls\[0\]\.axis\[1\]: an arc segment is refused"):
|
||||
_wall(axis=[[0, 0], {"center": [2500, 0], "radius": 2500}, [5000, 4000]])
|
||||
|
||||
|
||||
def test_an_unknown_material_is_refused_with_the_list():
|
||||
with pytest.raises(
|
||||
ValueError, match=r"^walls\[0\]\.material: unknown wall material 'adobe'"
|
||||
) as err:
|
||||
_wall(material="adobe")
|
||||
assert "brick, aac, concrete, reinforced_concrete" in str(err.value)
|
||||
|
||||
|
||||
def test_a_material_is_read_with_the_spelling_wall_hatch_accepts():
|
||||
"""The model must not refuse a name the hatch table resolves (spaces, hyphens, case)."""
|
||||
assert _wall(material="Reinforced Concrete").material == "reinforced_concrete"
|
||||
assert _wall(material="gypsum-board").material == "gypsum_board"
|
||||
|
||||
|
||||
def test_an_unknown_justification_and_an_unknown_field_are_refused():
|
||||
with pytest.raises(ValueError, match=r"^walls\[0\]\.justification: 'middle' is not one of"):
|
||||
_wall(justification="middle")
|
||||
with pytest.raises(ValueError, match=r"^walls\[0\]: unknown field\(s\) thicknes"):
|
||||
wall_from_dict({**L_WALL, "thicknes": 200}, "walls[0]")
|
||||
|
||||
|
||||
# -- openings --------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_opening_reads_its_defaults():
|
||||
assert _door() == Opening(
|
||||
id="D1", wall="W1", kind="door", offset=1000.0, width=900.0, swing="in", hand="left"
|
||||
)
|
||||
|
||||
|
||||
def test_an_opening_needs_a_positive_width_and_a_non_negative_offset():
|
||||
with pytest.raises(ValueError, match=r"^openings\[0\]\.width: must be greater than zero"):
|
||||
_door(width=0)
|
||||
with pytest.raises(ValueError, match=r"^openings\[0\]\.offset: must not be negative"):
|
||||
_door(offset=-1)
|
||||
assert _door(offset=0).offset == 0.0
|
||||
|
||||
|
||||
def test_an_opening_on_a_wall_that_is_not_in_the_plan_is_refused():
|
||||
with pytest.raises(ValueError, match=r"opening D1: wall 'W9' is not in the plan; walls are W1"):
|
||||
validate_openings([_wall()], [_door(wall="W9")])
|
||||
|
||||
|
||||
def test_an_opening_that_runs_past_its_wall_is_refused():
|
||||
with pytest.raises(
|
||||
ValueError, match=r"opening D1: runs past the end of wall W1 .* 9500 > axis length 9000"
|
||||
):
|
||||
validate_openings([_wall()], [_door(offset=8600)])
|
||||
|
||||
|
||||
def test_an_opening_that_straddles_an_axis_vertex_is_refused():
|
||||
# The axis turns at 5000 mm; 4500-5400 has a jamb on each side of the corner.
|
||||
with pytest.raises(
|
||||
ValueError, match=r"opening D1: straddles the axis vertex of wall W1 at 5000"
|
||||
):
|
||||
validate_openings([_wall()], [_door(offset=4500)])
|
||||
|
||||
|
||||
def test_an_opening_may_end_exactly_at_a_vertex():
|
||||
validate_openings([_wall()], [_door(offset=4100, width=900)])
|
||||
|
||||
|
||||
def test_two_overlapping_openings_are_refused_naming_both_ids():
|
||||
door = _door()
|
||||
window = opening_from_dict(
|
||||
{"id": "W2", "wall": "W1", "kind": "window", "offset": 1500, "width": 1200}, "openings[1]"
|
||||
)
|
||||
with pytest.raises(
|
||||
ValueError, match=r"openings D1 and W2 overlap on wall W1: 1000-1900 and 1500-2700"
|
||||
):
|
||||
validate_openings([_wall()], [door, window])
|
||||
|
||||
|
||||
def test_two_openings_that_only_touch_are_accepted():
|
||||
window = opening_from_dict(
|
||||
{"id": "W2", "wall": "W1", "kind": "window", "offset": 1900, "width": 1200}, "openings[1]"
|
||||
)
|
||||
validate_openings([_wall()], [_door(), window])
|
||||
|
||||
|
||||
def test_duplicate_ids_are_refused():
|
||||
with pytest.raises(ValueError, match=r"walls: two walls share the id 'W1'"):
|
||||
validate_openings([_wall(), _wall()], [])
|
||||
with pytest.raises(ValueError, match=r"openings: two openings share the id 'D1'"):
|
||||
validate_openings([_wall()], [_door(), _door(offset=3000)])
|
||||
|
||||
|
||||
def test_segment_of_finds_the_segment_and_the_distance_along_it():
|
||||
wall = _wall()
|
||||
assert segment_of(wall, 0.0) == (0, 0.0)
|
||||
assert segment_of(wall, 1000.0) == (0, 1000.0)
|
||||
assert segment_of(wall, 5000.0) == (1, 0.0) # a vertex belongs to the segment it starts
|
||||
assert segment_of(wall, 6500.0) == (1, 1500.0)
|
||||
assert segment_of(wall, 9000.0) == (1, 4000.0) # the very end belongs to the last one
|
||||
with pytest.raises(ValueError, match=r"offset 9001.0 is outside wall W1 \(axis length 9000\)"):
|
||||
segment_of(wall, 9001.0)
|
||||
|
||||
|
||||
def test_a_closed_wall_counts_its_closing_segment():
|
||||
ring = wall_from_dict(
|
||||
{
|
||||
"id": "R",
|
||||
"axis": [[0, 0], [4000, 0], [4000, 3000], [0, 3000]],
|
||||
"thickness": 200,
|
||||
"closed": True,
|
||||
},
|
||||
"walls[0]",
|
||||
)
|
||||
assert wall_length(ring) == 14000.0
|
||||
assert segment_of(ring, 12000.0) == (3, 1000.0)
|
||||
|
||||
|
||||
# -- stairs and rooms ------------------------------------------------------------
|
||||
|
||||
|
||||
STAIR = {
|
||||
"id": "S1",
|
||||
"start": [1000, 500],
|
||||
"direction_deg": 90,
|
||||
"width": 1000,
|
||||
"risers": 16,
|
||||
"riser_height": 175,
|
||||
"going": 280,
|
||||
}
|
||||
|
||||
|
||||
def test_a_stair_reads_its_defaults():
|
||||
assert stair_from_dict(STAIR, "stairs[0]") == Stair(
|
||||
id="S1",
|
||||
start=(1000.0, 500.0),
|
||||
direction_deg=90.0,
|
||||
width=1000.0,
|
||||
risers=16,
|
||||
riser_height=175.0,
|
||||
going=280.0,
|
||||
kind="straight",
|
||||
turn="left",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("over", "message"),
|
||||
[
|
||||
({"risers": 1}, r"^stairs\[0\]\.risers: a stair needs at least 2 risers, got 1"),
|
||||
({"risers": 15.5}, r"^stairs\[0\]\.risers: expected a whole number of risers"),
|
||||
({"going": -280}, r"^stairs\[0\]\.going: must be greater than zero"),
|
||||
({"riser_height": float("nan")}, r"^stairs\[0\]\.riser_height: must be finite"),
|
||||
({"kind": "spiral"}, r"^stairs\[0\]\.kind: 'spiral' is not one of straight, l, u"),
|
||||
],
|
||||
)
|
||||
def test_a_stair_refuses_by_path(over, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
stair_from_dict({**STAIR, **over}, "stairs[0]")
|
||||
|
||||
|
||||
def test_a_room_refuses_a_typed_area():
|
||||
with pytest.raises(ValueError, match=r"^rooms\[0\]\.area: an area is measured .* never typed"):
|
||||
room_from_dict(
|
||||
{"id": "R1", "name": "LIVING", "at": [2000, 2000], "area": 20.0e6}, "rooms[0]"
|
||||
)
|
||||
|
||||
|
||||
def test_a_room_reads_a_number_given_as_an_integer():
|
||||
room = room_from_dict({"id": "R1", "name": "LIVING", "number": 101, "at": [2000, 2000]})
|
||||
assert room == Room(id="R1", name="LIVING", number="101", at=(2000.0, 2000.0), area=0.0)
|
||||
|
||||
|
||||
# -- the ACADMCP_ARCH payload ----------------------------------------------------
|
||||
|
||||
|
||||
def test_the_codec_is_the_mechanical_one_under_our_own_application_id():
|
||||
assert APP_ID == "ACADMCP_ARCH"
|
||||
assert KINDS == ("wall", "opening", "stair", "room")
|
||||
rows = encode(to_payload(_wall()))
|
||||
assert rows[0] == (1001, "ACADMCP_ARCH")
|
||||
assert all(code == 1000 and len(value) <= 255 for code, value in rows[1:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"obj",
|
||||
[
|
||||
_wall(material="aac", justification="left"),
|
||||
_door(tag="D1", height=2100),
|
||||
stair_from_dict(STAIR),
|
||||
Room(id="R1", name="MUTFAK", number="2", at=(1.0, 2.0), area=12.5e6),
|
||||
],
|
||||
)
|
||||
def test_every_plan_object_survives_the_drawing(obj):
|
||||
payload = decode(to_values(to_payload(obj)))
|
||||
assert payload["kind"] in KINDS and payload["v"] == 1
|
||||
assert from_payload(payload) == obj
|
||||
|
||||
|
||||
def test_an_element_kind_travels_beside_the_payload_kind():
|
||||
payload = to_payload(_door())
|
||||
assert payload["kind"] == "opening" and payload["opening_kind"] == "door"
|
||||
assert to_payload(stair_from_dict(STAIR))["stair_kind"] == "straight"
|
||||
|
||||
|
||||
def test_a_mechanical_payload_is_not_ours():
|
||||
from engineering.mech.xdata import to_values as mech_values
|
||||
|
||||
part = {"v": 1, "kind": "part", "id": "P1", "view": "front", "part": {}}
|
||||
with pytest.raises(ValueError, match=r"ACADMCP_ARCH: unknown payload kind 'part'"):
|
||||
decode(mech_values(part))
|
||||
with pytest.raises(ValueError, match=r"belongs to application 'ACADMCP_MECH'"):
|
||||
decode([(1001, "ACADMCP_MECH"), (1000, "{}")])
|
||||
Reference in New Issue
Block a user