forked from 1b-agent/opencadstudio-upstream
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>
285 lines
14 KiB
Python
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()
|