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
|
||||
element types and requires an explicit unit contract for normalization. The
|
||||
bridge binds source layer IDs, uses the host MOPA setters, serializes review
|
||||
metadata and disables every staged step. It never authorizes encoding. The
|
||||
next import task is UI/preflight presentation plus independently established
|
||||
units; absent pulse width and image equivalence remain blockers.
|
||||
metadata and disables every staged step. Document preflight presents those
|
||||
blockers and the offline driver rejects them before buffer preparation. It
|
||||
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
|
||||
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
|
||||
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
|
||||
values, invalid numbers, image modes, mixed layers, exact layer binding,
|
||||
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,
|
||||
OfflineSessionState,
|
||||
)
|
||||
from .import_preflight import assert_no_unresolved_imports
|
||||
from .rayforge_compat import register_driver, unregister_driver
|
||||
|
||||
|
||||
|
|
@ -312,7 +313,8 @@ class PirateRayJCZNoDeviceDriver(NoDeviceDriver):
|
|||
async def run(
|
||||
self, encoded, doc, ops, on_command_done=None
|
||||
) -> None:
|
||||
del doc, ops
|
||||
del ops
|
||||
assert_no_unresolved_imports(doc)
|
||||
self.offline_session.prepare(encoded)
|
||||
self.offline_session.start()
|
||||
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": [
|
||||
"pirate_ray_jcz/__init__.py",
|
||||
"pirate_ray_jcz/import_preflight.py",
|
||||
"pirate_ray_jcz/offline_session.py",
|
||||
"pirate_ray_jcz/profile_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