Files
OneCAD-Draft/docs/automation/rest_smoke.py
T
huanldandClaude Opus 5.5 279a87418e feat(automation): entity, block, publishing and REST operations from the fork
The operations docs/automation/API-SPEC.md (PR #1383) describes, brought
over from huaninstratech/OpenCADStudio so the specification no longer runs
ahead of the code:

- entity CRUD: entities_create, entities_delete, entities_transform,
  entities_copy_to, and `where` filters on query
- blocks and export: block_define, block_delete, a normalised wblock
- publishing: layout_create, page_setup_set, plot (per_page included)
- metadata: sysvar, xdata_set / xdata_get, file_identity
- collections: group_create, selection_set_save / load
- control plane: batch, events, view_focus, close
- the REST transport: `--http <port>`, the /api/v1 routes and their
  OpenAPI 3 description
- person-in-the-loop picks: user_select and getpoint, hosted by the GUI
  over the same REST routes, with on-screen pending signals

Taken from the fork's commits b7a7ed80, 69396b42, 64f1cf6a, c3aafc8e,
bb5a7bfa, 52317a5c, 50d293ba, b90e194f, 517c6397, 53a5c4c9, a49ac045,
37de5a46, a78b1822, 6d9aed2a, 33f522da, 5b5cafe2, 02a7e78d, 94df40dc,
fd5bc14d, 485fac61 and 2c5fc994.

Left out on purpose: 37de5a46 also removed the donation surface (the
Patreon fetch, the donation prompt, the Start-page supporters rail, the
DONATE command and its locale strings) and replaced the About dialog's call
to action; that is the fork's own product decision and stays out. The
fork's version bump and its unrelated IFC/BIM work are not part of this.

Adapted while porting: the REST server and the GUI bridge are desktop-only
and a template start is refused in the web build, so the wasm target still
compiles; the two plot tests read the PDF through the shared stream helper,
because exported streams are Flate-compressed; the benches set the new
crosshair field; comments and docs describe each operation in its own
terms rather than by another product's API names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:41:07 +03:00

285 lines
14 KiB
Python

#!/usr/bin/env python3
"""Black-box smoke test for the REST transport (`--http <port>`).
Drives the whole entity lifecycle over plain HTTP — the same API surface any
language can call — against a real drawing session: create → verify by query
→ transform → mark with xdata → define a block → save → reopen → verify
everything persisted → erase → undo. Then exercises the P1 surface the same
way, always through real files: where filters, sysvars, layouts + page setups
+ per-page plotting to real PDFs, a .dwt template → new-from-template round
trip, a cross-document copy between two open documents, groups and selection
sets, and a close-with-discard. Uses only the Python standard library.
python3 docs/automation/rest_smoke.py target/debug/OpenCADStudio
"""
import json
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
def call(port: int, method: str, path: str, body=None):
"""One HTTP call; returns (status, parsed-json-or-None)."""
data = None
headers = {"Content-Type": "application/json"}
if body is not None:
data = json.dumps(body).encode()
request = urllib.request.Request(
f"http://127.0.0.1:{port}/api/v1{path}", data=data, headers=headers, method=method
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
raw = response.read()
return response.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as error:
raw = error.read()
return error.code, (json.loads(raw) if raw else None)
def wait_ready(process, port: int, timeout: float = 60.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
if process.poll() is not None:
raise SystemExit(f"server exited early: {process.returncode}")
try:
status, body = call(port, "GET", "/ready")
if status == 200 and body.get("ok"):
return
except (urllib.error.URLError, ConnectionError, OSError):
time.sleep(0.25)
raise SystemExit("server never became ready")
def expect(condition, message):
if not condition:
raise SystemExit(f"FAIL: {message}")
def main() -> None:
exe = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve()
port = 8091
out_dir = Path(tempfile.mkdtemp(prefix="ocs_rest_smoke_"))
process = subprocess.Popen(
[str(exe), "--http", str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
wait_ready(process, port)
# 0. Readiness + capability discovery.
status, ready = call(port, "GET", "/ready")
expect(status == 200 and ready["session_id"], f"ready: {ready}")
status, capabilities = call(port, "GET", "/capabilities")
expect(capabilities["operations"]["entities_create"] is True, "capabilities")
# 1. Create a batch of typed entities; the missing layer is created.
status, created = call(port, "POST", "/entities", {
"entities": [
{"type": "Line", "start": [0, 0], "end": [100, 0], "layer": "FRAME"},
{"type": "LwPolyline", "vertices": [[0, 0], [100, 0], [100, 60], [0, 60]], "closed": True},
{"type": "Text", "value": "PAGE-01", "position": [10, 50], "height": 3.0},
{"type": "Circle", "center": [80, 30], "radius": 5},
]
})
expect(status == 201 and created["result"]["created"] == 4, f"create: {created}")
handles = created["result"]["handles"]
expect(created["result"]["layers_created"] == ["FRAME"], "layer auto-created")
# 2. Verify by query — the geometry really landed.
status, query = call(port, "GET", "/entities?type=Line&detail=full")
expect(query["entities"][0]["end"] == [100.0, 0.0, 0.0], f"line geometry: {query}")
status, query = call(port, "GET", f"/entities/{handles[2]}")
expect(query["entities"][0]["value"] == "PAGE-01", "text content by handle")
# 3. Transform: move the circle and verify, then array the frame.
status, moved = call(port, "POST", "/entities/transform", {
"handles": [handles[3]], "action": "move", "vector": [10, 0],
})
expect(moved["result"]["affected"] == 1, f"move: {moved}")
status, query = call(port, "GET", f"/entities/{handles[3]}")
expect(query["entities"][0]["center"][0] == 90.0, "circle moved")
status, copied = call(port, "POST", "/entities/transform", {
"handles": [handles[0]], "action": "copy", "vector": [0, -10],
})
expect(len(copied["result"]["created"]) == 1, f"copy: {copied}")
# 4. Mark the text with extended data (RegApp registered implicitly).
status, marked = call(
port, "PUT", f"/entities/{handles[2]}/xdata/SPM",
[{"code": 1000, "value": "PAGE-01"}, {"code": 1070, "value": 3}],
)
expect(marked["result"]["updated"] == 1, f"xdata put: {marked}")
status, xdata = call(port, "GET", f"/entities/{handles[2]}/xdata?app=SPM")
expect(xdata["items"][0]["xdata"]["SPM"][0] == "PAGE-01", "xdata read")
# 5. Define a block from the two frame lines, BLOCK-command style.
status, block = call(port, "POST", "/blocks", {
"name": "FRAME-MARK", "base": [0, 0, 0], "handles": [handles[0], copied["result"]["created"][0]],
})
expect(status == 201 and block["result"]["block"] == "FRAME-MARK", f"block: {block}")
# 6. Save → reopen: everything survives the file round trip.
saved = out_dir / "rest_smoke.dwg"
status, saved_response = call(port, "POST", "/save", {"path": str(saved)})
expect(saved_response["ok"], f"save: {saved_response}")
status, opened = call(port, "POST", "/documents", {"path": str(saved)})
expect(opened["ok"], f"reopen: {opened}")
status, query = call(port, "GET", "/entities?type=Insert&detail=full")
expect(query["count"] == 1 and query["entities"][0]["block"] == "FRAME-MARK", "insert persisted")
status, query = call(port, "GET", "/entities?type=Text")
text_handle = query["entities"][0]["handle"]
status, xdata = call(port, "GET", f"/entities/{text_handle}/xdata?app=SPM")
expect(xdata["items"][0]["xdata"]["SPM"][1] == 3, "xdata persisted")
# 7. Erase + undo — the standard lifecycle close.
status, erased = call(port, "DELETE", f"/entities?handles={text_handle}")
expect(erased["result"]["erased"] == 1, f"erase: {erased}")
status, undone = call(port, "POST", "/undo")
expect(undone["ok"], f"undo: {undone}")
status, query = call(port, "GET", "/entities?type=Text")
expect(query["count"] == 1, "undo restored the text")
# 8. Where filters over entity properties (RFC 6901 pointers).
status, circles = call(port, "POST", "/entities", {
"entities": [
{"type": "Circle", "center": [0, -40], "radius": 2},
{"type": "Circle", "center": [30, -40], "radius": 8},
]
})
expect(status == 201, f"filter circles: {circles}")
where = urllib.parse.quote(json.dumps([{"path": "/radius", "op": "gt", "value": 6}]))
status, query = call(port, "GET", f"/entities?type=Circle&detail=geometry&where={where}")
expect(query["count"] == 1 and query["entities"][0]["radius"] == 8.0, f"where filter: {query}")
status, query = call(port, "GET", f"/entities?where={urllib.parse.quote('not-json')}")
expect(status == 400 and query["code"] == "invalid_where", f"bad where: {query}")
# 9. Sysvars: set over POST, read back over GET, unknown refused.
status, set_response = call(port, "POST", "/sysvars", {"set": {"ltscale": 3.5, "mirrtext": 1}})
expect(status == 200 and set_response["ok"], f"sysvar set: {set_response}")
status, read_back = call(port, "GET", "/sysvars?names=ltscale,mirrtext")
expect(read_back["result"]["values"]["ltscale"] == 3.5, f"sysvar read: {read_back}")
expect(read_back["result"]["values"]["mirrtext"] == 1, "mirrtext read back")
status, refused = call(port, "POST", "/sysvars", {"set": {"not_a_sysvar": 1}})
expect(status == 400 and refused["code"] == "unknown_sysvar", f"unknown sysvar: {refused}")
# 10. Sheets: a layout, its page setup, then one PDF per layout.
status, layout = call(port, "POST", "/layouts", {"name": "PLAN"})
expect(status == 201 and "PLAN" in layout["result"]["layouts"], f"layout: {layout}")
status, setup = call(port, "PUT", "/layouts/PLAN/page-setup", {
"paper": "ISO_A4_(210.00_x_297.00_MM)", "orientation": "landscape",
"fit": True, "center": True,
})
expect(status == 200 and "ISO_A4" in setup["result"]["paper"], f"page setup: {setup}")
plot_base = out_dir / "smoke_plot.pdf"
status, plotted = call(port, "POST", "/plot", {
"path": str(plot_base), "layout": "all", "per_page": True,
})
expect(plotted["ok"] and len(plotted["result"]["files"]) >= 2, f"per-page plot: {plotted}")
for entry in plotted["result"]["files"]:
pdf = Path(entry["path"])
expect(pdf.read_bytes().startswith(b"%PDF"), f"not a PDF: {pdf}")
pdf.unlink()
# 11. Template round trip: save as .dwt, then start the drawing from
# the template — the entities and their xdata survive.
status, query = call(port, "GET", "/entities")
count_before = query["count"]
template = out_dir / "rest_smoke.dwt"
status, saved_dwt = call(port, "POST", "/save", {"path": str(template)})
expect(saved_dwt["ok"], f"dwt save: {saved_dwt}")
expect(template.stat().st_size > 64, ".dwt written (DWG bytes)")
status, templated = call(port, "POST", "/documents", {"template": str(template)})
expect(templated["ok"] and templated["result"]["total"] == count_before,
f"new from template: {templated}")
status, query = call(port, "GET", "/entities?type=Text")
expect(query["count"] == 1, "text survived the template")
text_handle = query["entities"][0]["handle"]
status, xdata = call(port, "GET", f"/entities/{text_handle}/xdata?app=SPM")
expect(xdata["items"][0]["xdata"]["SPM"][0] == "PAGE-01", "xdata survived the template")
# 12. Cross-document copy: an empty POST /documents is
# A fresh second document in its own tab.
status, state = call(port, "GET", "/state")
source_doc = state["document_id"]
status, fresh = call(port, "POST", "/documents", {})
expect(status == 201 and fresh["ok"], f"second document: {fresh}")
status, state = call(port, "GET", "/state")
target_doc = state["document_id"]
expect(target_doc != source_doc, "two open documents")
status, switched = call(port, "POST", "/activate", {"document_id": source_doc})
expect(switched["ok"], f"activate source: {switched}")
status, query = call(port, "GET", "/entities?type=Line")
line_handles = [entity["handle"] for entity in query["entities"]]
expect(len(line_handles) == 2, f"source lines: {query}")
status, copy_op = call(port, "POST", "/entities/copy-to", {
"handles": line_handles, "document_id": target_doc,
})
expect(status == 201 and copy_op["result"]["count"] == 2, f"copy-to: {copy_op}")
status, switched = call(port, "POST", "/activate", {"document_id": target_doc})
expect(switched["ok"], f"activate target: {switched}")
status, query = call(port, "GET", "/entities?type=Line")
expect(query["count"] == 2, f"lines copied into the second document: {query}")
# 13. Groups, selection sets, block replace/delete and a stable file
# identity over the second document.
all_lines = [entity["handle"] for entity in query["entities"]]
status, group = call(port, "POST", "/groups", {
"name": "SMOKE-GROUP", "handles": all_lines,
})
expect(status == 201 and group["ok"], f"group: {group}")
status, selection = call(port, "POST", "/selection-sets", {
"name": "smoke-set", "handles": all_lines,
})
expect(status == 201 and selection["ok"], f"selection set save: {selection}")
status, recalled = call(port, "GET", "/selection-sets/smoke-set")
expect(len(recalled["result"]["handles"]) == 2, f"selection set load: {recalled}")
status, selected = call(port, "GET", "/selection-sets/smoke-set?select=true")
expect(selected["result"]["selected"] == 2, f"selection set select: {selected}")
status, identity = call(port, "POST", "/file-identity", {})
expect(status == 200 and len(identity["result"]["identity"]) == 36,
f"file identity: {identity}")
status, again = call(port, "POST", "/file-identity", {})
expect(again["result"]["identity"] == identity["result"]["identity"],
"file identity stable")
status, block = call(port, "POST", "/blocks", {
"name": "MARK", "base": [0, 0, 0], "handles": all_lines,
})
expect(status == 201, f"block define: {block}")
# The define consumed the source lines; replace wraps fresh content
# (SPM re-import draws new content and blocks it under the barcode).
status, fresh = call(port, "POST", "/entities", {
"entities": [{"type": "Point", "location": [9, 9]}],
})
expect(status == 201, f"fresh point: {fresh}")
status, replaced = call(port, "POST", "/blocks", {
"name": "MARK", "base": [0, 0, 0],
"handles": fresh["result"]["handles"], "replace": True,
})
expect(status == 201, f"block replace: {replaced}")
status, deleted = call(port, "DELETE", "/blocks/MARK")
expect(status == 200 and deleted["result"]["erased"] >= 1, f"block delete: {deleted}")
# 14. Close the dirty second document with discard.
status, closed = call(port, "DELETE", f"/documents/{target_doc}?discard=true")
expect(status == 200 and closed["result"]["closed"] is True, f"close: {closed}")
status, state = call(port, "GET", "/state")
expect(state["document_id"] == source_doc, "back on the first document")
print("rest smoke: OK")
finally:
process.terminate()
process.wait(timeout=30)
if __name__ == "__main__":
main()