Block unresolved LightBurn imports in offline driver preflight
This commit is contained in:
parent
be3fb933c1
commit
a9fb945af4
6 changed files with 169 additions and 4 deletions
|
|
@ -82,9 +82,11 @@ synthetic tests now exist; see
|
||||||
`docs/research/lightburn-project-mopa-import.md`. It preserves both process
|
`docs/research/lightburn-project-mopa-import.md`. It preserves both process
|
||||||
element types and requires an explicit unit contract for normalization. The
|
element types and requires an explicit unit contract for normalization. The
|
||||||
bridge binds source layer IDs, uses the host MOPA setters, serializes review
|
bridge binds source layer IDs, uses the host MOPA setters, serializes review
|
||||||
metadata and disables every staged step. It never authorizes encoding. The
|
metadata and disables every staged step. Document preflight presents those
|
||||||
next import task is UI/preflight presentation plus independently established
|
blockers and the offline driver rejects them before buffer preparation. It
|
||||||
units; absent pulse width and image equivalence remain blockers.
|
never authorizes encoding. The next import task is GTK presentation plus an
|
||||||
|
independently established unit contract; absent pulse width and image
|
||||||
|
equivalence remain blockers.
|
||||||
|
|
||||||
If a Windows artifact is available, keep the machine disconnected and run, in
|
If a Windows artifact is available, keep the machine disconnected and run, in
|
||||||
order:
|
order:
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,12 @@ machine, lens, correction and parameter limits before a separate reviewed
|
||||||
promotion path can exist. Rayforge's relevant Step and LaserStep files are now
|
promotion path can exist. Rayforge's relevant Step and LaserStep files are now
|
||||||
included in the pinned upstream contract manifest.
|
included in the pinned upstream contract manifest.
|
||||||
|
|
||||||
|
The addon now also exposes a transport-free document preflight report with
|
||||||
|
stable issue codes and human-readable blocker messages. The offline driver
|
||||||
|
runs this check before it prepares encoded buffers. A staged import is rejected;
|
||||||
|
manually re-enabling its step adds a distinct error, and malformed review data
|
||||||
|
fails closed. There is intentionally no accepted or promoted import state yet.
|
||||||
|
|
||||||
Validation covers unit conversion, absent fields, case sensitivity, duplicate
|
Validation covers unit conversion, absent fields, case sensitivity, duplicate
|
||||||
values, invalid numbers, image modes, mixed layers, exact layer binding,
|
values, invalid numbers, image modes, mixed layers, exact layer binding,
|
||||||
transactional rollback and malformed XML. Existing packaging, no-device and
|
transactional rollback and malformed XML. Existing packaging, no-device and
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""Present and enforce unresolved PirateRay workflow import blockers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .workflow_import import METADATA_KEY
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPreflightError(RuntimeError):
|
||||||
|
"""A staged import is not eligible for job execution."""
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(items: Iterable[Any]):
|
||||||
|
for item in items:
|
||||||
|
yield item
|
||||||
|
children = getattr(item, "children", ())
|
||||||
|
if children:
|
||||||
|
yield from _walk(children)
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_imported_workflow(doc: Any) -> dict[str, Any]:
|
||||||
|
"""Return stable, UI-ready errors for every staged import in a document."""
|
||||||
|
issues = []
|
||||||
|
items = getattr(doc, "children", None)
|
||||||
|
if items is None:
|
||||||
|
issues.append({
|
||||||
|
"code": "IMPORT_DOCUMENT_INVALID",
|
||||||
|
"severity": "error",
|
||||||
|
"message": "Document tree is unavailable for PirateRay preflight.",
|
||||||
|
})
|
||||||
|
return {"ready": False, "issues": issues}
|
||||||
|
|
||||||
|
for step in _walk(items):
|
||||||
|
extra = getattr(step, "extra", None)
|
||||||
|
if not isinstance(extra, Mapping) or METADATA_KEY not in extra:
|
||||||
|
continue
|
||||||
|
record = extra[METADATA_KEY]
|
||||||
|
name = getattr(step, "name", "Imported workflow step")
|
||||||
|
if not isinstance(record, Mapping):
|
||||||
|
issues.append({
|
||||||
|
"code": "IMPORT_METADATA_INVALID", "severity": "error",
|
||||||
|
"step": name,
|
||||||
|
"message": "PirateRay import review data is malformed.",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
layer_id = record.get("source_layer_id")
|
||||||
|
if getattr(step, "visible", False):
|
||||||
|
issues.append({
|
||||||
|
"code": "IMPORT_OUTPUT_REENABLED", "severity": "error",
|
||||||
|
"step": name, "source_layer_id": layer_id,
|
||||||
|
"message": "Imported output was re-enabled before review completed.",
|
||||||
|
})
|
||||||
|
blockers = record.get("blockers")
|
||||||
|
if not isinstance(blockers, list) or not blockers or not all(
|
||||||
|
isinstance(value, str) and value for value in blockers
|
||||||
|
):
|
||||||
|
issues.append({
|
||||||
|
"code": "IMPORT_METADATA_INVALID", "severity": "error",
|
||||||
|
"step": name, "source_layer_id": layer_id,
|
||||||
|
"message": "PirateRay import blockers are missing or malformed.",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
issues.append({
|
||||||
|
"code": "IMPORT_REVIEW_REQUIRED", "severity": "error",
|
||||||
|
"step": name, "source_layer_id": layer_id,
|
||||||
|
"message": "Imported MOPA settings require review: " + "; ".join(blockers),
|
||||||
|
"blockers": list(blockers),
|
||||||
|
})
|
||||||
|
return {"ready": not issues, "issues": issues}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_unresolved_imports(doc: Any) -> None:
|
||||||
|
"""Fail before an encoded job reaches even the offline session."""
|
||||||
|
report = inspect_imported_workflow(doc)
|
||||||
|
if not report["ready"]:
|
||||||
|
codes = ", ".join(issue["code"] for issue in report["issues"])
|
||||||
|
raise ImportPreflightError(f"PirateRay import preflight failed: {codes}")
|
||||||
|
|
@ -23,6 +23,7 @@ from .offline_session import (
|
||||||
OfflineJCZSession,
|
OfflineJCZSession,
|
||||||
OfflineSessionState,
|
OfflineSessionState,
|
||||||
)
|
)
|
||||||
|
from .import_preflight import assert_no_unresolved_imports
|
||||||
from .rayforge_compat import register_driver, unregister_driver
|
from .rayforge_compat import register_driver, unregister_driver
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -312,7 +313,8 @@ class PirateRayJCZNoDeviceDriver(NoDeviceDriver):
|
||||||
async def run(
|
async def run(
|
||||||
self, encoded, doc, ops, on_command_done=None
|
self, encoded, doc, ops, on_command_done=None
|
||||||
) -> None:
|
) -> None:
|
||||||
del doc, ops
|
del ops
|
||||||
|
assert_no_unresolved_imports(doc)
|
||||||
self.offline_session.prepare(encoded)
|
self.offline_session.prepare(encoded)
|
||||||
self.offline_session.start()
|
self.offline_session.start()
|
||||||
op_count = encoded.op_map.op_count if encoded.op_map else 0
|
op_count = encoded.op_map.op_count if encoded.op_map else 0
|
||||||
|
|
|
||||||
1
tests/fixtures/rayforge-addon-spike.json
vendored
1
tests/fixtures/rayforge-addon-spike.json
vendored
|
|
@ -17,6 +17,7 @@
|
||||||
],
|
],
|
||||||
"packaged_files": [
|
"packaged_files": [
|
||||||
"pirate_ray_jcz/__init__.py",
|
"pirate_ray_jcz/__init__.py",
|
||||||
|
"pirate_ray_jcz/import_preflight.py",
|
||||||
"pirate_ray_jcz/offline_session.py",
|
"pirate_ray_jcz/offline_session.py",
|
||||||
"pirate_ray_jcz/profile_import.py",
|
"pirate_ray_jcz/profile_import.py",
|
||||||
"pirate_ray_jcz/project_import.py",
|
"pirate_ray_jcz/project_import.py",
|
||||||
|
|
|
||||||
75
tests/test_lightburn_import_preflight.py
Normal file
75
tests/test_lightburn_import_preflight.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parents[1]
|
||||||
|
ADDON = ROOT / "spikes" / "rayforge-jcz-no-device-addon"
|
||||||
|
sys.path.insert(0, str(ADDON))
|
||||||
|
from pirate_ray_jcz.import_preflight import (
|
||||||
|
ImportPreflightError, assert_no_unresolved_imports, inspect_imported_workflow,
|
||||||
|
)
|
||||||
|
from pirate_ray_jcz.workflow_import import METADATA_KEY
|
||||||
|
|
||||||
|
|
||||||
|
def document(*steps):
|
||||||
|
return SimpleNamespace(children=[SimpleNamespace(children=list(steps))])
|
||||||
|
|
||||||
|
|
||||||
|
def imported_step(*, visible=False, record=None):
|
||||||
|
if record is None:
|
||||||
|
record = {
|
||||||
|
"status": "staged-review-required",
|
||||||
|
"source_layer_id": "3",
|
||||||
|
"blockers": ["machine identity unverified", "unit contract unresolved"],
|
||||||
|
"encoding_allowed": False,
|
||||||
|
"hardware_use_allowed": False,
|
||||||
|
}
|
||||||
|
return SimpleNamespace(name="Imported contour", visible=visible,
|
||||||
|
extra={METADATA_KEY: record}, children=[])
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPreflightTests(unittest.TestCase):
|
||||||
|
def test_plain_document_is_ready(self):
|
||||||
|
report = inspect_imported_workflow(document(
|
||||||
|
SimpleNamespace(name="ordinary", visible=True, extra={}, children=[])))
|
||||||
|
self.assertEqual(report, {"ready": True, "issues": []})
|
||||||
|
assert_no_unresolved_imports(document())
|
||||||
|
|
||||||
|
def test_staged_import_is_presented_and_execution_is_rejected(self):
|
||||||
|
doc = document(imported_step())
|
||||||
|
report = inspect_imported_workflow(doc)
|
||||||
|
self.assertFalse(report["ready"])
|
||||||
|
self.assertEqual(["IMPORT_REVIEW_REQUIRED"],
|
||||||
|
[issue["code"] for issue in report["issues"]])
|
||||||
|
self.assertIn("unit contract unresolved", report["issues"][0]["message"])
|
||||||
|
with self.assertRaisesRegex(ImportPreflightError, "IMPORT_REVIEW_REQUIRED"):
|
||||||
|
assert_no_unresolved_imports(doc)
|
||||||
|
|
||||||
|
def test_manual_reenable_adds_a_distinct_error(self):
|
||||||
|
report = inspect_imported_workflow(document(imported_step(visible=True)))
|
||||||
|
self.assertEqual(
|
||||||
|
["IMPORT_OUTPUT_REENABLED", "IMPORT_REVIEW_REQUIRED"],
|
||||||
|
[issue["code"] for issue in report["issues"]],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_malformed_metadata_and_document_fail_closed(self):
|
||||||
|
for doc in (
|
||||||
|
object(),
|
||||||
|
document(imported_step(record="bad")),
|
||||||
|
document(imported_step(record={"source_layer_id": "3", "blockers": []})),
|
||||||
|
):
|
||||||
|
with self.subTest(doc=doc):
|
||||||
|
report = inspect_imported_workflow(doc)
|
||||||
|
self.assertFalse(report["ready"])
|
||||||
|
self.assertTrue(any("INVALID" in issue["code"] for issue in report["issues"]))
|
||||||
|
|
||||||
|
def test_offline_driver_runs_preflight_before_session_prepare(self):
|
||||||
|
source = (ADDON / "pirate_ray_jcz" / "worker.py").read_text(encoding="utf-8")
|
||||||
|
preflight = source.index("assert_no_unresolved_imports(doc)")
|
||||||
|
prepare = source.index("self.offline_session.prepare(encoded)")
|
||||||
|
self.assertLess(preflight, prepare)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Reference in a new issue