merge: track F group R

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:
Umut CAN
2026-09-23 23:03:13 +03:00
co-authored by Claude Opus 5.5
16 changed files with 2769 additions and 15 deletions
+240
View File
@@ -0,0 +1,240 @@
"""The planar-face finder: segments in, closed faces out, areas measured.
Every expected coordinate and area below is computed by hand in the comment
beside it; nothing is read back from the implementation.
"""
from __future__ import annotations
import re
import pytest
from engineering.arch.faces import Face, face_containing, planar_faces, tagged_faces
EPS = 1e-9
def rect(x0, y0, x1, y1):
"""The four sides of an axis-aligned rectangle as segments."""
return [
((x0, y0), (x1, y0)),
((x1, y0), (x1, y1)),
((x1, y1), (x0, y1)),
((x0, y1), (x0, y0)),
]
def two_rooms():
"""Two 4000 x 5000 rooms between 200 mm walls, as clean outlines.
Outer outline (0,0)-(8600,5400). Inner face of the exterior walls
(200,200)-(8400,5200), with the shared wall's two faces x=4200 and x=4400
stopping on it at T-junctions - exactly what a clean wall engine draws.
Room A is (200,200)-(4200,5200), room B (4400,200)-(8400,5200).
"""
return [
*rect(0.0, 0.0, 8600.0, 5400.0),
*rect(200.0, 200.0, 8400.0, 5200.0),
((4200.0, 200.0), (4200.0, 5200.0)),
((4400.0, 200.0), (4400.0, 5200.0)),
]
def test_one_rectangle_is_one_ccw_face_starting_low_left():
(face,) = planar_faces(rect(0.0, 0.0, 4000.0, 3000.0))
assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0))
assert abs(face.area - 12_000_000.0) < EPS # 4000 x 3000
assert face.centroid == pytest.approx((2000.0, 1500.0), abs=EPS)
assert face.holes == ()
def test_the_same_rectangle_drawn_clockwise_is_still_a_ccw_face():
clockwise = [
((0.0, 0.0), (0.0, 3000.0)),
((0.0, 3000.0), (4000.0, 3000.0)),
((4000.0, 3000.0), (4000.0, 0.0)),
((4000.0, 0.0), (0.0, 0.0)),
]
(face,) = planar_faces(clockwise)
assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0))
def test_two_adjacent_rooms_are_two_room_faces_plus_the_wall_bodies():
faces = planar_faces(two_rooms())
# room A, room B, the shared wall body, and the exterior ring
assert len(faces) == 4
room_a = face_containing(faces, (2200.0, 2700.0))
room_b = face_containing(faces, (6400.0, 2700.0))
assert abs(room_a.area - 20_000_000.0) < EPS # 4000 x 5000
assert abs(room_b.area - 20_000_000.0) < EPS
assert room_a.loop == ((200.0, 200.0), (4200.0, 200.0), (4200.0, 5200.0), (200.0, 5200.0))
assert room_b.loop == ((4400.0, 200.0), (8400.0, 200.0), (8400.0, 5200.0), (4400.0, 5200.0))
assert room_a.centroid == pytest.approx((2200.0, 2700.0), abs=EPS)
shared = face_containing(faces, (4300.0, 2700.0))
assert abs(shared.area - 1_000_000.0) < EPS # 200 x 5000
ring = face_containing(faces, (100.0, 2700.0))
# 8600 x 5400 = 46 440 000 minus the inner outline 8200 x 5000 = 41 000 000
assert abs(ring.area - 5_440_000.0) < EPS
# the hole is the inner outline, clockwise, split where the shared wall meets it
assert ring.holes == (
(
(200.0, 200.0),
(200.0, 5200.0),
(4200.0, 5200.0),
(4400.0, 5200.0),
(8400.0, 5200.0),
(8400.0, 200.0),
(4400.0, 200.0),
(4200.0, 200.0),
),
)
# the ring's net region is symmetric about the outline's centre
assert ring.centroid == pytest.approx((4300.0, 2700.0), abs=1e-6)
def test_a_4000_by_5000_room_between_200_mm_walls_measures_20_m2_not_the_axis_area():
# walls on axes (-100,-100)-(4100,5100); the axis rectangle is 4200 x 5200
faces = planar_faces([*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)])
room = face_containing(faces, (2000.0, 2500.0))
assert abs(room.area - 20_000_000.0) < EPS # 20.00 m2, not 21.84 m2
wall = face_containing(faces, (-100.0, 2500.0))
# 4400 x 5400 = 23 760 000 minus 20 000 000
assert abs(wall.area - 3_760_000.0) < EPS
def test_a_t_junction_splits_the_line_it_touches():
# a partition from (2000,0) to (2000,3000) stops on both long sides
faces = planar_faces([*rect(0.0, 0.0, 4000.0, 3000.0), ((2000.0, 0.0), (2000.0, 3000.0))])
assert len(faces) == 2
left, right = faces
assert left.loop == ((0.0, 0.0), (2000.0, 0.0), (2000.0, 3000.0), (0.0, 3000.0))
assert right.loop == ((2000.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (2000.0, 3000.0))
assert abs(left.area - 6_000_000.0) < EPS # 2000 x 3000
assert abs(right.area - 6_000_000.0) < EPS
def test_a_t_touch_within_tol_still_splits():
# the partition stops 0.5 mm short of the bottom side: within tol=1.0
faces = planar_faces(
[*rect(0.0, 0.0, 4000.0, 3000.0), ((2000.0, 0.5), (2000.0, 3000.0))], tol=1.0
)
assert len(faces) == 2
# the split vertex is the partition's own end (2000, 0.5), which bends the
# bottom side by 0.5 mm; each half is 2000 x 3000 minus a 0.5 mm-high sliver
# triangle of base 2000: 6 000 000 - 0.5 * 2000 * 0.5 = 5 999 500
assert [round(f.area, 6) for f in faces] == [5_999_500.0, 5_999_500.0]
def test_crossing_lines_split_each_other_and_their_overshoots_are_pruned():
segments = [
*rect(0.0, 0.0, 4000.0, 3000.0),
((-500.0, 1500.0), (4500.0, 1500.0)), # overshoots 500 at both ends
((2000.0, -500.0), (2000.0, 3500.0)),
]
faces = planar_faces(segments)
assert len(faces) == 4
assert all(abs(f.area - 3_000_000.0) < EPS for f in faces) # 2000 x 1500 each
assert faces[0].loop == ((0.0, 0.0), (2000.0, 0.0), (2000.0, 1500.0), (0.0, 1500.0))
def test_a_dangling_segment_is_ignored():
segments = [
*rect(0.0, 0.0, 4000.0, 3000.0),
((4000.0, 3000.0), (5000.0, 4000.0)), # a spur off a corner
((1000.0, 1000.0), (1500.0, 1200.0)), # a stray line inside the room
]
(face,) = planar_faces(segments)
assert abs(face.area - 12_000_000.0) < EPS
def test_endpoints_within_tol_merge_and_a_wider_gap_leaves_the_loop_open():
closing = ((0.0, 3000.4), (0.0, 0.0)) # starts 0.4 mm off the corner (0, 3000)
sides = [
((0.0, 0.0), (4000.0, 0.0)),
((4000.0, 0.0), (4000.0, 3000.0)),
((4000.0, 3000.0), (0.0, 3000.0)),
]
(face,) = planar_faces([*sides, closing], tol=1.0)
# (0, 3000) was seen first, so it is the vertex: the area is exact
assert abs(face.area - 12_000_000.0) < EPS
open_gap = ((0.0, 2995.0), (0.0, 0.0)) # stops 5 mm short of (0, 3000)
assert planar_faces([*sides, open_gap], tol=1.0) == ()
def test_an_overshoot_through_a_corner_still_closes_the_loop():
sides = [
((0.0, 0.0), (4000.0, 0.0)),
((4000.0, 0.0), (4000.0, 3000.0)),
((4000.0, 3000.0), (0.0, 3000.0)),
]
# the closing side runs 5 mm past the corner (0, 3000): the corner splits it
# and the 5 mm stub is a dangling edge
(face,) = planar_faces([*sides, ((0.0, 3005.0), (0.0, 0.0))])
assert face.loop == ((0.0, 0.0), (4000.0, 0.0), (4000.0, 3000.0), (0.0, 3000.0))
assert abs(face.area - 12_000_000.0) < EPS
def test_collinear_overlaps_become_one_edge():
segments = [
((0.0, 0.0), (3000.0, 0.0)),
((1000.0, 0.0), (4000.0, 0.0)), # overlaps the first from 1000 to 3000
((4000.0, 0.0), (4000.0, 3000.0)),
((4000.0, 3000.0), (0.0, 3000.0)),
((0.0, 3000.0), (0.0, 0.0)),
]
(face,) = planar_faces(segments)
assert face.loop == (
(0.0, 0.0),
(1000.0, 0.0),
(3000.0, 0.0),
(4000.0, 0.0),
(4000.0, 3000.0),
(0.0, 3000.0),
)
assert abs(face.area - 12_000_000.0) < EPS
def test_a_column_inside_a_room_is_a_hole_in_the_room():
segments = [*rect(0.0, 0.0, 4000.0, 3000.0), *rect(1000.0, 1000.0, 1400.0, 1400.0)]
faces = planar_faces(segments)
room = face_containing(faces, (3000.0, 2000.0))
column = face_containing(faces, (1200.0, 1200.0))
assert abs(room.area - 11_840_000.0) < EPS # 12 000 000 - 400 x 400
assert abs(column.area - 160_000.0) < EPS
assert len(room.holes) == 1
# net centroid: (12e6 * (2000, 1500) - 160 000 * (1200, 1200)) / 11 840 000
assert room.centroid == pytest.approx(
(
(12e6 * 2000.0 - 160_000.0 * 1200.0) / 11_840_000.0,
(12e6 * 1500.0 - 160_000.0 * 1200.0) / 11_840_000.0,
),
abs=1e-6,
)
def test_a_point_in_no_face_answers_none():
faces = planar_faces(rect(0.0, 0.0, 4000.0, 3000.0))
assert face_containing(faces, (5000.0, 5000.0)) is None
def test_edge_tags_ride_along_to_every_face_edge():
tagged = [(p1, p2, "WALL") for p1, p2 in rect(0.0, 0.0, 4000.0, 3000.0)]
tagged[0] = (tagged[0][0], tagged[0][1], "0")
((face, tags),) = tagged_faces(tagged)
assert tags == (frozenset({"0"}), frozenset({"WALL"}), frozenset({"WALL"}), frozenset({"WALL"}))
assert isinstance(face, Face)
@pytest.mark.parametrize(
("segments", "tol", "message"),
[
([((0.0, 0.0),)], 1.0, "segments[0]: expected (p1, p2) or (p1, p2, tag), got 1 items"),
([((0.0, "x"), (1.0, 1.0))], 1.0, "segments[0][0][1]: expected a number, got 'x'"),
([((0.0, float("nan")), (1.0, 1.0))], 1.0, "segments[0][0][1]: must be finite"),
(rect(0.0, 0.0, 1.0, 1.0), 0.0, "tol: must be greater than zero, got 0"),
],
)
def test_bad_input_is_refused_by_path(segments, tol, message):
with pytest.raises(ValueError, match=re.escape(message)):
planar_faces(segments, tol=tol)
+546
View File
@@ -0,0 +1,546 @@
"""Room labels with a measured area, and the foreign-plan reader.
The pure half asserts emitted primitives and hand-computed coordinates; the
async half runs against the headless backend (the `backend` fixture in
tests/conftest.py). The wall and opening records a drawn plan carries are
written with `model.to_values` directly - the wall engine that writes them in
production is Task 4's, on another branch, and the record shape is pinned by
the skeleton.
"""
from __future__ import annotations
import re
import pytest
from engineering.arch.layers import ARCH_ROLE_LAYER
from engineering.arch.model import APP_ID, Opening, Room, Wall, decode, to_payload, to_values
from engineering.arch.rooms import (
CONFIDENCE_FOREIGN,
CONFIDENCE_OURS,
detect_rooms,
label_room,
opening_closures,
read_records,
room_label_prims,
rooms_detect,
)
from engineering.mech.primitives import Text
WALL = ARCH_ROLE_LAYER["wall"]
ROOM = ARCH_ROLE_LAYER["room"]
EPS = 1e-9
def rect(x0, y0, x1, y1):
return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]
def two_rooms(tag=None):
"""Two 4000 x 5000 rooms between 200 mm walls (see tests/test_arch_faces.py)."""
segments = [
*rect(0.0, 0.0, 8600.0, 5400.0),
*rect(200.0, 200.0, 8400.0, 5200.0),
((4200.0, 200.0), (4200.0, 5200.0)),
((4400.0, 200.0), (4400.0, 5200.0)),
]
return segments if tag is None else [(p1, p2, tag) for p1, p2 in segments]
async def draw_lines(backend, segments, layer):
for (x1, y1), (x2, y2) in segments:
await backend.entity_create_line(x1, y1, x2, y2, layer=layer)
async def write_record(backend, obj, layer):
"""A record the way a drawn plan carries it: XDATA on an entity of its layer."""
carrier = await backend.entity_create_line(0.0, -1000.0, 1.0, -1000.0, layer=layer)
await backend.entity_set_xdata(carrier.handle, APP_ID, to_values(to_payload(obj)))
return carrier.handle
async def texts(backend, layer):
return {
e.properties.get("text"): e
for e in await backend.entity_list(type_filter="TEXT", layer_filter=layer, limit=1000)
}
# ── pure: the label ─────────────────────────────────────────────────────────
def test_label_prims_stack_name_number_area_down_from_the_point_at_plot_scale():
room = Room(id="R1", name="LIVING", number="01", at=(2000.0, 2500.0), area=20_000_000.0)
prims = room_label_prims(room, lang="en", scale=50)
# 1:50 -> name 3.5 x 50 = 175, number and area 2.5 x 50 = 125;
# baselines 2500, 2500 - 1.6 x 125 = 2300, 2300 - 1.6 x 125 = 2100
assert prims == (
Text(at=(2000.0, 2500.0), text="LIVING", height=175.0, rotation=0.0, role="room"),
Text(at=(2000.0, 2300.0), text="01", height=125.0, rotation=0.0, role="room"),
Text(at=(2000.0, 2100.0), text="20.00 m²", height=125.0, rotation=0.0, role="room"),
)
def test_label_without_a_number_puts_the_area_on_the_second_line_and_tr_uses_a_comma():
room = Room(id="R2", name="SALON", number=None, at=(0.0, 0.0), area=24_500_000.0)
prims = room_label_prims(room, lang="tr", scale=100)
# 1:100 -> name 350, area 250 at 0 - 1.6 x 250 = -400
assert [(p.at, p.text, p.height) for p in prims] == [
((0.0, 0.0), "SALON", 350.0),
((0.0, -400.0), "24,50 m²", 250.0),
]
@pytest.mark.parametrize(
("kwargs", "message"),
[
({"lang": "de"}, "lang: 'de' is not a label language; languages are en, tr"),
({"scale": 0}, "scale: must be a finite number greater than zero, got 0"),
],
)
def test_label_prims_refuse_an_unknown_language_or_scale(kwargs, message):
room = Room(id="R1", name="A", number=None, at=(0.0, 0.0), area=1.0)
with pytest.raises(ValueError, match=re.escape(message)):
room_label_prims(room, **kwargs)
# ── pure: openings close their gap ──────────────────────────────────────────
def test_an_opening_is_closed_along_both_wall_faces():
# the shared wall's axis runs up x=4300 from y=100 to y=5300, 200 thick, centred;
# a 900 door 1100 from the axis start spans y=1200..2100.
wall = Wall(id="w5", axis=((4300.0, 100.0), (4300.0, 5300.0)), thickness=200.0)
door = Opening(id="d1", wall="w5", kind="door", offset=1100.0, width=900.0)
closures, omitted = opening_closures([wall], [door])
# u = (0, 1), left normal n = (-1, 0); face offsets -100 and +100
assert closures == (
((4400.0, 1200.0), (4400.0, 2100.0)),
((4200.0, 1200.0), (4200.0, 2100.0)),
)
assert omitted == ()
def test_a_left_justified_wall_closes_its_faces_on_the_axis_and_one_thickness_left():
wall = Wall(id="w1", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0, justification="left")
window = Opening(id="p1", wall="w1", kind="window", offset=1000.0, width=1200.0)
closures, _ = opening_closures([wall], [window])
# u = (1, 0), n = (0, 1): faces at 0 and +200
assert closures == (((1000.0, 0.0), (2200.0, 0.0)), ((1000.0, 200.0), (2200.0, 200.0)))
def test_an_opening_whose_wall_has_no_record_is_omitted_not_guessed():
door = Opening(id="d9", wall="nowhere", kind="door", offset=0.0, width=900.0)
closures, omitted = opening_closures([], [door])
assert closures == ()
assert omitted == (
{
"element": "d9",
"reason": "host wall 'nowhere' has no record on this drawing; its gap is left open",
},
)
def test_a_stale_opening_past_the_end_of_its_wall_is_omitted_not_raised():
# a record left behind after its wall was shortened: 9000 on a 5000 axis.
# The reader is read-only and must still answer for every other room.
wall = Wall(id="w1", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0)
stale = Opening(id="d4", wall="w1", kind="door", offset=9000.0, width=900.0)
closures, omitted = opening_closures([wall], [stale])
assert closures == ()
assert omitted == (
{
"element": "d4",
"reason": "offset 9000.0 is outside wall w1 (axis length 5000); its gap is left open",
},
)
# ── pure: detect_rooms ──────────────────────────────────────────────────────
def test_detect_rooms_keeps_the_rooms_and_drops_the_wall_bodies():
rooms = detect_rooms(two_rooms(WALL))
# the exterior ring (mean width 2 x 5 440 000 / 54 400 = 200) and the shared
# wall body (2 x 1 000 000 / 10 400 = 192) are wall bodies, not rooms
assert [r["area"] for r in rooms] == [20_000_000.0, 20_000_000.0]
assert [r["centroid"] for r in rooms] == [(2200.0, 2700.0), (6400.0, 2700.0)]
assert {r["confidence"] for r in rooms} == {CONFIDENCE_OURS}
def test_one_foreign_edge_makes_the_face_foreign():
segments = two_rooms(WALL)
# the inner outline's bottom edge from (200,200) to (8400,200) becomes a plain line
segments[4] = (segments[4][0], segments[4][1], "0")
rooms = detect_rooms(segments)
assert [r["confidence"] for r in rooms] == [CONFIDENCE_FOREIGN, CONFIDENCE_FOREIGN]
def test_untagged_lines_are_foreign_and_min_area_filters():
rooms = detect_rooms(two_rooms())
assert [r["confidence"] for r in rooms] == [0.6, 0.6]
assert detect_rooms(two_rooms(), min_area=20_000_001.0) == ()
# ── async: label_room ───────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_a_4000_by_5000_room_between_200_mm_walls_is_labelled_20_m2(backend):
await draw_lines(
backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL
)
result = await label_room(
backend, {"name": "LIVING", "number": "01", "at": [2000.0, 2500.0]}, lang="en", scale=50
)
assert result["room"]["area"] == pytest.approx(20_000_000.0, abs=EPS)
assert result["room"]["area_text"] == "20.00 m²"
assert result["room"]["id"] == "R1"
assert result["loop"] == [[0.0, 0.0], [4000.0, 0.0], [4000.0, 5000.0], [0.0, 5000.0]]
labels = await texts(backend, ROOM)
assert set(labels) == {"LIVING", "01", "20.00 m²"}
raw = await backend.entity_get_xdata(result["handle"], APP_ID)
payload = decode(raw["xdata"][APP_ID])
assert payload["kind"] == "room"
assert payload["id"] == "R1"
assert payload["area"] == pytest.approx(20_000_000.0, abs=EPS)
@pytest.mark.asyncio
async def test_turkish_labels_use_a_decimal_comma(backend):
await draw_lines(
backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL
)
await label_room(backend, {"name": "SALON", "at": [2000.0, 2500.0]}, lang="tr")
assert "20,00 m²" in await texts(backend, ROOM)
@pytest.mark.asyncio
async def test_a_door_in_the_shared_wall_does_not_merge_the_two_rooms(backend):
door_gap = [
*rect(0.0, 0.0, 8600.0, 5400.0),
*rect(200.0, 200.0, 8400.0, 5200.0),
# the shared wall's faces, interrupted from y=1200 to y=2100, and its two jambs
((4200.0, 200.0), (4200.0, 1200.0)),
((4200.0, 2100.0), (4200.0, 5200.0)),
((4400.0, 200.0), (4400.0, 1200.0)),
((4400.0, 2100.0), (4400.0, 5200.0)),
((4200.0, 1200.0), (4400.0, 1200.0)),
((4200.0, 2100.0), (4400.0, 2100.0)),
]
await draw_lines(backend, door_gap, WALL)
await write_record(
backend, Wall(id="w5", axis=((4300.0, 100.0), (4300.0, 5300.0)), thickness=200.0), WALL
)
await write_record(
backend,
Opening(id="d1", wall="w5", kind="door", offset=1100.0, width=900.0),
ARCH_ROLE_LAYER["door"],
)
result = await label_room(backend, {"name": "A", "at": [2200.0, 2700.0]})
# without the closures room A would read 20 000 000 + 20 000 000 + 900 x 200
assert result["room"]["area"] == pytest.approx(20_000_000.0, abs=EPS)
assert result["closures"] == 2
@pytest.mark.asyncio
async def test_label_room_refusals_leave_the_drawing_untouched(backend):
await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), WALL)
before = await backend.entity_count()
with pytest.raises(ValueError, match=re.escape("room.area: a room's area is measured")):
await label_room(backend, {"name": "A", "at": [100.0, 100.0], "area": 5.0})
with pytest.raises(ValueError, match=re.escape("room.at: (9000, 9000) lies in no closed face")):
await label_room(backend, {"name": "A", "at": [9000.0, 9000.0]})
with pytest.raises(ValueError, match=re.escape("lang: 'fr' is not a label language")):
await label_room(backend, {"name": "A", "at": [100.0, 100.0]}, lang="fr")
assert await backend.entity_count() == before
await label_room(backend, {"id": "R7", "name": "A", "at": [100.0, 100.0]})
with pytest.raises(ValueError, match=re.escape("room.id: 'R7' is already labelled")):
await label_room(backend, {"id": "R7", "name": "B", "at": [200.0, 200.0]})
@pytest.mark.asyncio
async def test_a_label_point_inside_a_wall_body_is_refused(backend):
await draw_lines(
backend, [*rect(-200.0, -200.0, 4200.0, 5200.0), *rect(0.0, 0.0, 4000.0, 5000.0)], WALL
)
with pytest.raises(
ValueError, match=re.escape("room.at: (-100, 2500) lies inside a wall body")
):
await label_room(backend, {"name": "A", "at": [-100.0, 2500.0]})
# ── async: rooms_detect ─────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_a_foreign_plan_of_plain_lines_yields_the_same_areas_and_is_not_modified(backend):
await backend.entity_create_polyline(
[[0.0, 0.0], [8600.0, 0.0], [8600.0, 5400.0], [0.0, 5400.0]], True, layer="DUVAR"
)
await draw_lines(backend, two_rooms()[4:], "DUVAR")
await draw_lines(backend, [((-500.0, -500.0), (9000.0, -500.0))], "0") # not read
before = await backend.entity_count()
result = await rooms_detect(backend)
assert result["layers"] == ["DUVAR"]
assert [r["area"] for r in result["rooms"]] == [20_000_000.0, 20_000_000.0]
assert [r["centroid"] for r in result["rooms"]] == [[2200.0, 2700.0], [6400.0, 2700.0]]
assert result["confidence_min"] == CONFIDENCE_FOREIGN
assert result["modified"] is False
assert await backend.entity_count() == before
@pytest.mark.asyncio
async def test_our_own_walls_read_at_full_confidence_with_their_labels(backend):
await draw_lines(backend, two_rooms(), WALL)
await label_room(backend, {"name": "A", "number": "01", "at": [2200.0, 2700.0]})
result = await rooms_detect(backend)
assert result["layers"] == [WALL]
assert [r["confidence"] for r in result["rooms"]] == [1.0, 1.0]
first, second = result["rooms"]
assert first["label"] == {"id": "R1", "name": "A", "number": "01", "area": 20_000_000.0}
assert second["label"] is None
@pytest.mark.asyncio
async def test_the_record_reader_pages_the_drawing_once_not_once_per_arch_layer(
backend, monkeypatch
):
# a live seat pays one cross-process call per entity per scan; twelve arch
# layers must not mean twelve scans of the whole space
await write_record(
backend, Wall(id="w1", axis=((0.0, 0.0), (5000.0, 0.0)), thickness=200.0), WALL
)
await write_record(
backend,
Opening(id="d1", wall="w1", kind="door", offset=1000.0, width=900.0),
ARCH_ROLE_LAYER["door"],
)
await backend.entity_create_line(0.0, 0.0, 1.0, 1.0, layer="0") # not an arch layer
calls = []
original = backend.entity_list
async def counting(*args, **kwargs):
calls.append(kwargs)
return await original(*args, **kwargs)
monkeypatch.setattr(backend, "entity_list", counting)
records = await read_records(backend)
assert len(calls) == 1
assert [w.id for w in records["walls"]] == ["w1"]
assert [o.id for o in records["openings"]] == ["d1"]
assert records["unreadable"] == 0
@pytest.mark.asyncio
async def test_rooms_detect_refuses_a_layer_that_is_not_there(backend):
await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), "WALLS")
with pytest.raises(ValueError, match=re.escape("layers: NOPE not in this drawing")):
await rooms_detect(backend, layers=["NOPE"])
@pytest.mark.asyncio
async def test_an_arc_on_a_wall_layer_is_reported_skipped_never_approximated(backend):
await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), "WALLS")
arc = await backend.entity_create_arc(2000.0, 5000.0, 500.0, 0.0, 180.0, layer="WALLS")
result = await rooms_detect(backend)
assert [r["area"] for r in result["rooms"]] == [20_000_000.0]
assert [s["handle"] for s in result["skipped"]] == [arc.handle]
# ── what the reader cannot read is skipped, by name ─────────────────────────
def outline(x0, y0, x1, y1):
return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)]
@pytest.mark.asyncio
async def test_an_old_style_polyline_is_skipped_by_name_not_dropped(backend):
# one room of old-style 2D POLYLINEs next to one of lightweight polylines;
# the headless engine reports the old-style kind with no vertices at all
msp = backend._doc.modelspace()
heavy = [
msp.add_polyline2d(outline(*box), close=True, dxfattribs={"layer": "WALLS"}).dxf.handle
for box in ((0.0, 0.0, 4400.0, 5400.0), (200.0, 200.0, 4200.0, 5200.0))
]
for box in ((10000.0, 0.0, 14400.0, 5400.0), (10200.0, 200.0, 14200.0, 5200.0)):
await backend.entity_create_polyline([list(p) for p in outline(*box)], True, layer="WALLS")
result = await rooms_detect(backend)
assert [r["area"] for r in result["rooms"]] == [20_000_000.0]
assert [(s["handle"], s["type"]) for s in result["skipped"]] == [
(heavy[0], "POLYLINE"),
(heavy[1], "POLYLINE"),
]
assert "old-style polyline" in result["skipped"][0]["reason"]
@pytest.mark.asyncio
async def test_walls_inside_a_block_reference_are_skipped_and_annotation_is_passed_over(
backend,
):
await draw_lines(backend, rect(0.0, 0.0, 4000.0, 5000.0), "WALLS")
block = backend._doc.blocks.new("ROOMBLK")
for (x1, y1), (x2, y2) in [
*rect(0.0, 0.0, 4400.0, 5400.0),
*rect(200.0, 200.0, 4200.0, 5200.0),
]:
block.add_line((x1, y1), (x2, y2))
insert = backend._doc.modelspace().add_blockref(
"ROOMBLK", (20000.0, 0.0), dxfattribs={"layer": "WALLS"}
)
await backend.entity_create_text("W1", 100.0, 100.0, 100.0, layer="WALLS")
result = await rooms_detect(backend)
assert [r["area"] for r in result["rooms"]] == [20_000_000.0]
assert [(s["handle"], s["type"]) for s in result["skipped"]] == [(insert.dxf.handle, "INSERT")]
assert "walls inside a block are not read" in result["skipped"][0]["reason"]
class LiveRows:
"""A backend that answers with the EntityInfo rows a live seat produced.
The rows are the ones `backends.com_backend._entity_info` returned on
AutoCAD 2026 (2026-09-23) for three closed ``ModelSpace.AddPolyline``
outlines on WALLS - outer 0..8600 x 0..5400, rooms 200..4200 and
4400..8400 x 200..5200 - whose ``Coordinates`` came back as x, y, z
triples and were read two at a time; plus an ``AddLightWeightPolyline``
pair (AcDbPolyline, reported POLYLINE) and a block reference. Read
through, the old-style rows gave five faces of 2.6 to 6.9 m².
"""
def __init__(self):
from backends.base import EntityInfo, LayerInfo
def row(handle, kind, points=None):
props = {} if points is None else {"points": points, "closed": True}
if points is not None:
props["bulges"] = [0.0] * len(points)
return EntityInfo(handle, kind, "WALLS", 256, "ByLayer", True, props)
self.rows = [
row(
"2A1",
"2DPOLYLINE",
[
[0.0, 0.0],
[0.0, 8600.0],
[0.0, 0.0],
[8600.0, 5400.0],
[0.0, 0.0],
[5400.0, 0.0],
],
),
row(
"2A2",
"2DPOLYLINE",
[
[200.0, 200.0],
[0.0, 4200.0],
[200.0, 0.0],
[4200.0, 5200.0],
[0.0, 200.0],
[5200.0, 0.0],
],
),
row(
"2A3",
"2DPOLYLINE",
[
[4400.0, 200.0],
[0.0, 8400.0],
[200.0, 0.0],
[8400.0, 5200.0],
[0.0, 4400.0],
[5200.0, 0.0],
],
),
row("2A4", "POLYLINE", [list(p) for p in outline(10000.0, 0.0, 14400.0, 5400.0)]),
row("2A5", "POLYLINE", [list(p) for p in outline(10200.0, 200.0, 14200.0, 5200.0)]),
row("2A6", "BLOCKREFERENCE"),
]
self.layers = [
LayerInfo(name, 7, "Continuous", 0.25, True, False, False, name == "0")
for name in ("0", "WALLS")
]
async def layer_list(self):
return self.layers
async def entity_list(self, type_filter=None, layer_filter=None, limit=200, offset=0):
rows = [r for r in self.rows if layer_filter is None or r.layer == layer_filter]
return rows[offset : offset + limit]
async def entity_get_xdata(self, handle, app_id):
return {"xdata": {}}
@pytest.mark.asyncio
async def test_live_old_style_polylines_are_skipped_not_read_as_their_garbled_points():
result = await rooms_detect(LiveRows())
# only the lightweight pair is a room; the garbled rows make no faces at all
assert [r["area"] for r in result["rooms"]] == [20_000_000.0]
assert result["confidence_min"] == CONFIDENCE_FOREIGN
assert [(s["handle"], s["type"]) for s in result["skipped"]] == [
("2A1", "2DPOLYLINE"),
("2A2", "2DPOLYLINE"),
("2A3", "2DPOLYLINE"),
("2A6", "BLOCKREFERENCE"),
]
# ── one face, one room record ───────────────────────────────────────────────
@pytest.mark.asyncio
async def test_a_second_label_in_a_labelled_room_is_refused_and_draws_nothing(backend):
await draw_lines(
backend, [*rect(0.0, 0.0, 4400.0, 5400.0), *rect(200.0, 200.0, 4200.0, 5200.0)], WALL
)
first = await label_room(backend, {"name": "LIVING", "at": [1000.0, 3000.0]})
before = await backend.entity_count()
with pytest.raises(
ValueError,
match=re.escape(
f"room.at: (2000, 3000) lies in a room that is already labelled - "
f"R1 'LIVING' (label {first['handle']})"
),
):
await label_room(backend, {"name": "LIVING2", "at": [2000.0, 3000.0]})
assert await backend.entity_count() == before
result = await rooms_detect(backend)
assert [r["labels"] for r in result["rooms"]] == [
[{"id": "R1", "name": "LIVING", "number": None, "area": 20_000_000.0}]
]
assert result["label_conflicts"] == []
@pytest.mark.asyncio
async def test_the_other_room_can_still_be_labelled(backend):
await draw_lines(backend, two_rooms(), WALL)
await label_room(backend, {"name": "A", "at": [2200.0, 2700.0]})
second = await label_room(backend, {"name": "B", "at": [6400.0, 2700.0]})
assert second["room"]["id"] == "R2"
assert second["room"]["area"] == pytest.approx(20_000_000.0, abs=EPS)
@pytest.mark.asyncio
async def test_rooms_detect_reports_every_record_in_a_face_and_the_conflict(backend):
# two records already on one face (written by other means than arch_room)
await draw_lines(backend, two_rooms(), WALL)
for rid in ("R1", "R2"):
room = Room(id=rid, name=rid, number=None, at=(2200.0, 2700.0), area=20_000_000.0)
await write_record(backend, room, ROOM)
result = await rooms_detect(backend)
first, second = result["rooms"]
assert [label["id"] for label in first["labels"]] == ["R1", "R2"]
assert first["label"]["id"] == "R1"
assert second["labels"] == [] and second["label"] is None
assert result["label_conflicts"] == [
{
"labels": ["R1", "R2"],
"centroid": [2200.0, 2700.0],
"reason": "one face carries 2 room records; a schedule would count its "
"20.00 m2 2 times",
}
]
+77
View File
@@ -0,0 +1,77 @@
"""arch_room and arch_rooms_detect over the MCP wire, on a fresh headless document."""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
import server
from engineering.arch.layers import ARCH_ROLE_LAYER
pytestmark = pytest.mark.asyncio
WALL = ARCH_ROLE_LAYER["wall"]
def rect(x0, y0, x1, y1):
return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document - never the operator's AutoCAD."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
async def draw_room(client, layer):
"""A 4000 x 5000 room between 200 mm walls: outer and inner outline."""
for (x1, y1), (x2, y2) in [
*rect(-200.0, -200.0, 4200.0, 5200.0),
*rect(0.0, 0.0, 4000.0, 5000.0),
]:
await client.call_tool(
"entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": layer}
)
async def test_arch_room_labels_the_measured_area(client):
await draw_room(client, WALL)
result = await client.call_tool(
"arch_room", {"name": "LIVING", "number": "01", "x": 2000.0, "y": 2500.0}
)
room = result.structured_content["room"]
assert room["area"] == pytest.approx(20_000_000.0)
assert room["area_text"] == "20.00 m²"
assert result.structured_content["layer"] == ARCH_ROLE_LAYER["room"]
async def test_arch_rooms_detect_reads_the_room_back_without_drawing(client):
await draw_room(client, "DUVAR")
before = (await client.call_tool("analysis_entity_stats", {})).structured_content
result = (await client.call_tool("arch_rooms_detect", {})).structured_content
assert result["layers"] == ["DUVAR"]
assert [r["area"] for r in result["rooms"]] == [pytest.approx(20_000_000.0)]
assert result["confidence_min"] == 0.6
after = (await client.call_tool("analysis_entity_stats", {})).structured_content
assert after["total_entities"] == before["total_entities"]
async def test_a_point_outside_every_room_is_refused_over_the_wire(client):
await draw_room(client, WALL)
result = await client.call_tool(
"arch_room", {"name": "X", "x": 9000.0, "y": 9000.0}, raise_on_error=False
)
assert result.is_error is True
assert "room.at: (9000, 9000) lies in no closed face" in result.content[0].text
async def test_the_detector_is_advertised_read_only(client):
tools = {tool.name: tool for tool in await client.list_tools()}
assert tools["arch_rooms_detect"].annotations.readOnlyHint is True
assert tools["arch_room"].annotations.destructiveHint is False
+200
View File
@@ -0,0 +1,200 @@
"""Door, window and room schedules: rows from the plan model, a TABLE from the drawing.
Records are written with `model.to_values` directly - in production the wall
engine (Task 4, another branch) and `arch_room` write them; the record shape is
pinned by the skeleton.
"""
from __future__ import annotations
import re
import pytest
from engineering.arch.lang import vocab
from engineering.arch.layers import ARCH_ROLE_LAYER
from engineering.arch.model import APP_ID, Opening, Room, Wall, to_payload, to_values
from engineering.arch.schedule import SCHEDULE_COLUMNS, draw_schedule, schedule_rows
WALLS = [
Wall(id="w1", axis=((0.0, 0.0), (6000.0, 0.0)), thickness=200.0),
Wall(id="w2", axis=((6000.0, 0.0), (6000.0, 4000.0)), thickness=200.0),
]
def plan():
return {
"walls": list(WALLS),
"openings": [
Opening(
id="da",
wall="w2",
kind="door",
offset=500.0,
width=800.0,
swing="out",
hand="right",
),
Opening(id="db", wall="w1", kind="door", offset=3000.0, width=912.5, height=2100.0),
Opening(
id="dc", wall="w1", kind="door", offset=1000.0, width=900.0, height=2100.0, tag="D2"
),
Opening(
id="wa",
wall="w1",
kind="window",
offset=4000.0,
width=1200.0,
height=1200.0,
sill=900.0,
),
],
"rooms": [
Room(id="R1", name="KITCHEN", number="2", at=(0.0, 0.0), area=15_000_000.0),
Room(id="R2", name="LIVING", number="10", at=(0.0, 0.0), area=20_000_000.0),
Room(id="R3", name="STORE", number=None, at=(0.0, 0.0), area=10_000_000.0),
],
}
def test_door_rows_generate_missing_tags_in_wall_order_and_sort_by_tag():
rows = schedule_rows("doors", plan(), lang="en")
en = vocab("en")
# wall order: w1 at 1000 (tagged D2), w1 at 3000 -> D1, w2 at 500 -> D3 (D2 is taken)
assert [(r["id"], r["tag"], r["generated"]) for r in rows] == [
("db", "D1", True),
("dc", "D2", False),
("da", "D3", True),
]
assert rows[0] == {
"id": "db",
"tag": "D1",
"generated": True,
"width": "912.5",
"height": "2100",
"swing": en["in"],
"hand": en["left"],
"wall": "w1",
}
# no height on the model: an empty cell, never a default
assert rows[2]["height"] == ""
assert (rows[2]["swing"], rows[2]["hand"]) == (en["out"], en["right"])
assert set(SCHEDULE_COLUMNS["doors"]) <= set(rows[0])
def test_turkish_door_rows_use_k_tags_decimal_commas_and_turkish_words():
rows = schedule_rows("doors", plan(), lang="tr")
tr = vocab("tr")
# explicit "D2" is never renamed; generated tags take the Turkish prefix
assert [r["tag"] for r in rows] == ["D2", "K1", "K2"]
k1 = next(r for r in rows if r["tag"] == "K1")
assert k1["width"] == "912,5"
assert (k1["swing"], k1["hand"]) == (tr["in"], tr["left"])
def test_window_rows_carry_sill_and_no_swing():
(row,) = schedule_rows("windows", plan(), lang="en")
assert row == {
"id": "wa",
"tag": "W1",
"generated": True,
"width": "1200",
"height": "1200",
"sill": "900",
"wall": "w1",
}
assert [r["tag"] for r in schedule_rows("windows", plan(), lang="tr")] == ["P1"]
def test_room_rows_sort_by_number_with_the_unnumbered_last_and_end_in_a_total():
rows = schedule_rows("rooms", plan(), lang="en")
assert [(r["number"], r["name"], r["area"]) for r in rows] == [
("2", "KITCHEN", "15.00 m²"),
("10", "LIVING", "20.00 m²"),
("", "STORE", "10.00 m²"),
("", vocab("en")["total"], "45.00 m²"), # 15 + 20 + 10
]
assert rows[-1]["total"] is True
assert rows[-1]["area_mm2"] == 45_000_000.0
def test_turkish_room_rows_write_the_total_with_a_decimal_comma():
rows = schedule_rows("rooms", plan(), lang="tr")
assert (rows[-1]["name"], rows[-1]["area"]) == (vocab("tr")["total"], "45,00 m²")
assert rows[0]["area"] == "15,00 m²"
def test_an_empty_plan_has_no_rows_and_no_total():
assert schedule_rows("rooms", {"walls": [], "openings": [], "rooms": []}) == ()
assert schedule_rows("doors", {"walls": [], "openings": [], "rooms": []}) == ()
@pytest.mark.parametrize(
("kind", "lang", "message"),
[
("stairs", "en", "kind: 'stairs' is not a schedule; schedules are doors, windows, rooms"),
("doors", "de", "lang: 'de' is not a schedule language; languages are en, tr"),
],
)
def test_an_unknown_kind_or_language_is_refused_with_the_list(kind, lang, message):
with pytest.raises(ValueError, match=re.escape(message)):
schedule_rows(kind, plan(), lang=lang)
# ── async: the TABLE ────────────────────────────────────────────────────────
async def write_record(backend, obj, role):
carrier = await backend.entity_create_line(0.0, 0.0, 1.0, 0.0, layer=ARCH_ROLE_LAYER[role])
await backend.entity_set_xdata(carrier.handle, APP_ID, to_values(to_payload(obj)))
async def write_plan(backend):
data = plan()
for wall in data["walls"]:
await write_record(backend, wall, "wall")
for opening in data["openings"]:
await write_record(backend, opening, opening.kind)
for room in data["rooms"]:
await write_record(backend, room, "room")
@pytest.mark.asyncio
async def test_a_door_schedule_is_a_table_with_title_header_and_one_row_per_door(backend):
await write_plan(backend)
result = await draw_schedule(backend, "doors", at=(10_000.0, 5_000.0), lang="en", scale=50)
assert result["row_count"] == 3
assert result["table_rows"] == 5 # title + header + 3 doors
assert result["representation"] == "composite"
assert result["layer"] == ARCH_ROLE_LAYER["symbol"]
assert result["title"] == vocab("en")["door_schedule"]
# 1:50: widths 15+20+20+20+20+20 = 115 mm -> 5750; rows 5 x 7 mm x 50 = 1750
assert result["bbox"] == {"min": [10_000.0, 3_250.0], "max": [15_750.0, 5_000.0]}
cells = {
e.properties["text"]
for e in await backend.entity_list(
type_filter="MTEXT", layer_filter=ARCH_ROLE_LAYER["symbol"], limit=1000
)
}
assert {"D1", "D2", "D3", "912.5", "w2"} <= cells
@pytest.mark.asyncio
async def test_a_room_schedule_carries_the_total_row(backend):
await write_plan(backend)
result = await draw_schedule(backend, "rooms", at=(0.0, 0.0), lang="tr")
assert result["row_count"] == 4 # three rooms + the total
assert result["table_rows"] == 6
assert result["rows"][-1]["area"] == "45,00 m²"
@pytest.mark.asyncio
async def test_nothing_to_schedule_is_refused_before_anything_is_drawn(backend):
before = await backend.entity_count()
with pytest.raises(ValueError, match=re.escape("kind: this drawing carries no window records")):
await draw_schedule(backend, "windows", at=(0.0, 0.0))
with pytest.raises(
ValueError, match=re.escape("scale: must be a finite number greater than zero")
):
await draw_schedule(backend, "doors", at=(0.0, 0.0), scale=-1)
assert await backend.entity_count() == before
+65
View File
@@ -0,0 +1,65 @@
"""arch_schedule over the MCP wire: rooms labelled by arch_room, scheduled as a TABLE."""
from __future__ import annotations
import pytest
import pytest_asyncio
from fastmcp import Client
import server
from engineering.arch.layers import ARCH_ROLE_LAYER
pytestmark = pytest.mark.asyncio
WALL = ARCH_ROLE_LAYER["wall"]
def rect(x0, y0, x1, y1):
return [((x0, y0), (x1, y0)), ((x1, y0), (x1, y1)), ((x1, y1), (x0, y1)), ((x0, y1), (x0, y0))]
@pytest_asyncio.fixture
async def client(monkeypatch):
"""In-memory client on a fresh headless document - never the operator's AutoCAD."""
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
async with Client(server.mcp) as connected:
status = (await connected.call_tool("system_status", {})).structured_content or {}
assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD"
await connected.call_tool("drawing_new", {})
yield connected
async def draw_two_rooms(client):
"""Rooms (200,200)-(4200,5200) and (4400,200)-(8400,5200) between 200 mm walls."""
for (x1, y1), (x2, y2) in [
*rect(0.0, 0.0, 8600.0, 5400.0),
*rect(200.0, 200.0, 8400.0, 5200.0),
((4200.0, 200.0), (4200.0, 5200.0)),
((4400.0, 200.0), (4400.0, 5200.0)),
]:
await client.call_tool(
"entity_create_line", {"x1": x1, "y1": y1, "x2": x2, "y2": y2, "layer": WALL}
)
async def test_a_room_schedule_reads_the_labels_arch_room_wrote(client):
await draw_two_rooms(client)
await client.call_tool("arch_room", {"name": "LIVING", "number": "1", "x": 2200.0, "y": 2700.0})
await client.call_tool("arch_room", {"name": "BED", "number": "2", "x": 6400.0, "y": 2700.0})
result = (
await client.call_tool("arch_schedule", {"kind": "rooms", "x": 9000.0, "y": 5400.0})
).structured_content
assert result["table_rows"] == 5 # title + header + two rooms + total
assert [(r["number"], r["area"]) for r in result["rows"]] == [
("1", "20.00 m²"),
("2", "20.00 m²"),
("", "40.00 m²"),
]
async def test_nothing_to_schedule_is_refused_over_the_wire(client):
result = await client.call_tool(
"arch_schedule", {"kind": "doors", "x": 0.0, "y": 0.0}, raise_on_error=False
)
assert result.is_error is True
assert "kind: this drawing carries no door records" in result.content[0].text
+74
View File
@@ -135,3 +135,77 @@ async def test_server_registers_table_and_mleader_tools():
names = {tool.name for tool in await server._registered_tools()}
assert "entity_create_table" in names
assert "leader_create_mleader" in names
class _MeasuredTable:
"""The IAcadTable members a live AutoCAD 2026 seat exposes (measured).
MEASURED on AutoCAD 2026 (track F task 6): the table has no ``TextHeight``
property - pywin32 answers ``table.TextHeight = h`` with AttributeError -
and a table left alone keeps the Standard style's heights (title 6.0, header
and data 4.5). ``SetCellTextHeight(row, col, h)`` is the member that sets it.
A fake that accepted ``TextHeight`` would hide exactly that defect.
"""
def __init__(self, rows, columns):
self.Handle = "A1"
self.Rows, self.Columns = rows, columns
self._heights = {
(r, c): (6.0 if r == 0 else 4.5) for r in range(rows) for c in range(columns)
}
def __setattr__(self, name, value):
if name == "TextHeight":
raise AttributeError(f"object has no attribute {name!r}")
object.__setattr__(self, name, value)
def SetColumnWidth(self, column, width):
pass
def SetRowHeight(self, row, height):
pass
def SetText(self, row, column, value):
pass
def SetCellTextHeight(self, row, column, height):
self._heights[(row, column)] = float(height)
def GetCellTextHeight(self, row, column):
return self._heights[(row, column)]
async def test_com_table_sets_every_cell_text_height_through_the_measured_member():
backend = ComBackend()
async def run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
backend._run = run_inline
mspace = MagicMock()
made: list[_MeasuredTable] = []
def add_table(_point, rows, columns, _row_height, _column_width):
made.append(_MeasuredTable(rows, columns))
return made[-1]
mspace.AddTable.side_effect = add_table
info = EntityInfo("A1", "ACAD_TABLE", "TEXT", 256, "ByLayer", True, {})
with (
patch("backends.com_backend._msp", return_value=mspace),
patch("backends.com_backend._apoint", side_effect=lambda *p: tuple(p)),
patch("backends.com_backend._entity_info", return_value=info),
patch("backends.com_backend._apply_entity_attrs"),
patch("backends.com_backend._regen"),
):
await backend.entity_create_table(
0, 50, [["A", "1"]], headers=["Name", "Qty"], title="BOM", text_height=125.0
)
(table,) = made
assert {
(row, column): table.GetCellTextHeight(row, column)
for row in range(table.Rows)
for column in range(table.Columns)
} == {(row, column): 125.0 for row in range(3) for column in range(2)}
+10 -3
View File
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
async def test_tool_group_sizes_are_unchanged():
"""Frozen snapshot of the surface (233 tools, 26 groups).
"""Frozen snapshot of the surface (236 tools, 26 groups).
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
@@ -587,11 +587,18 @@ async def test_tool_group_sizes_are_unchanged():
`arch_dimension_chains`); the `arch` tag is ranked directly after `mech` in
`_GROUP_TAG_PRIORITY`, so those tools file under `architecture` rather
than under their secondary `create` / `dimension` tags.
Track F group R opened SECTION 25 with the rooms: `arch_room` and
`arch_schedule` are tagged `arch` and `create`, `arch_rooms_detect` `arch`
and `query`. `arch` is not in `_GROUP_TAG_PRIORITY` on this branch, so they
file under their secondary tags: `entity_creation` 18 -> 20,
`entity_query` 9 -> 10. This snapshot is branch-local: the track F merge
task recomputes it once the walls, rooms and catalogue branches have landed.
"""
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
assert sizes == {
"analysis": 12,
"architecture": 4,
"architecture": 7,
"batch": 3,
"blocks": 9,
"corner_ops": 4,
@@ -617,4 +624,4 @@ async def test_tool_group_sizes_are_unchanged():
"validation": 1,
"view": 4,
}
assert sum(sizes.values()) == 233
assert sum(sizes.values()) == 236