Stage imported MOPA settings in disabled Rayforge workflows
This commit is contained in:
parent
583a0d573a
commit
be3fb933c1
9 changed files with 327 additions and 20 deletions
|
|
@ -68,20 +68,23 @@ application until both gates below have real evidence:
|
||||||
reviewed build runner, verified without execution, and pass the manual
|
reviewed build runner, verified without execution, and pass the manual
|
||||||
no-device desktop checklist.
|
no-device desktop checklist.
|
||||||
2. The known-working private JPT `.lbrn2` serialization evidence gate is met.
|
2. The known-working private JPT `.lbrn2` serialization evidence gate is met.
|
||||||
Import mapping still requires explicit unit conversion and fail-closed
|
Offline mapping and disabled-output workflow staging now have synthetic
|
||||||
handling of absent pulse width, with synthetic offline tests.
|
tests. Promotion still requires an independently established unit contract,
|
||||||
|
explicit resolution of absent pulse width and machine-context validation.
|
||||||
|
|
||||||
Raw projects, screenshots containing identifiers, machine data and build
|
Raw projects, screenshots containing identifiers, machine data and build
|
||||||
binaries stay out of the public repository.
|
binaries stay out of the public repository.
|
||||||
|
|
||||||
## Suggested first task
|
## Suggested first task
|
||||||
|
|
||||||
An offline project MOPA seam and synthetic tests now exist; see
|
An offline project MOPA seam, disabled-output Rayforge workflow bridge and
|
||||||
|
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. It is
|
element types and requires an explicit unit contract for normalization. The
|
||||||
not wired into Rayforge's workflow and never authorizes encoding. The next
|
bridge binds source layer IDs, uses the host MOPA setters, serializes review
|
||||||
import task is host integration with layer identity and independently
|
metadata and disables every staged step. It never authorizes encoding. The
|
||||||
established units; absent pulse width and image equivalence remain blockers.
|
next import task is UI/preflight presentation plus independently established
|
||||||
|
units; 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:
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,22 @@ settings remain blocked pending image-processing equivalence, including
|
||||||
|
|
||||||
Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and
|
Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and
|
||||||
`transport=None`, even with complete normalized values. This is a callable
|
`transport=None`, even with complete normalized values. This is a callable
|
||||||
offline metadata seam; it is not yet wired to Rayforge's project importer or
|
offline metadata seam. The companion workflow bridge matches the normalized
|
||||||
workflow. Host integration must bind settings to layer indices, establish
|
LightBurn layer index to Rayforge's source-segment layer ID and stages complete
|
||||||
serialized units from independent evidence, resolve absent pulse width and
|
vector MOPA values through the host's setters. Every staged step is disabled
|
||||||
validate exact machine, lens, correction and parameter limits. No existing
|
through `Step.set_visible(False)`, and its raw values, normalized values,
|
||||||
golden lifecycle or upstream importer run was repeated; the package inventory
|
review status and blockers are saved in the serializable `Step.extra` data.
|
||||||
was extended to include the new module.
|
Image settings and incomplete settings remain disabled without applying MOPA
|
||||||
|
values. Multi-step staging is preflighted and rolled back on setter failure.
|
||||||
|
|
||||||
|
The bridge deliberately has no function that enables output or removes a
|
||||||
|
blocker. Host UI and preflight integration must still establish serialized
|
||||||
|
units from independent evidence, resolve absent pulse width and validate exact
|
||||||
|
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.
|
||||||
|
|
||||||
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 and malformed XML. Existing
|
values, invalid numbers, image modes, mixed layers, exact layer binding,
|
||||||
packaging, no-device and public evidence checks remain part of the test suite.
|
transactional rollback and malformed XML. Existing packaging, no-device and
|
||||||
|
public evidence checks remain part of the test suite.
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ its manifest and sidecar; installer-only publication is not accepted.
|
||||||
| `register_machines` hook | Documented but unusable for this driver today | The hook exists, but the supplied manager does not expose driver-class registration |
|
| `register_machines` hook | Documented but unusable for this driver today | The hook exists, but the supplied manager does not expose driver-class registration |
|
||||||
| `NoDeviceDriver`, `OpsEncoder`, `EncodedOutput`, `MachineCodeOpMap` | Imported host interfaces | Required by the addon; pin and contract-test them before upgrades |
|
| `NoDeviceDriver`, `OpsEncoder`, `EncodedOutput`, `MachineCodeOpMap` | Imported host interfaces | Required by the addon; pin and contract-test them before upgrades |
|
||||||
| `register_driver`, `drivers`, `driver_by_classname` | Internal registry | Functional but unsuitable as a permanent public contract; `register_driver` is not exported in `__all__` and no symmetric unregister function exists |
|
| `register_driver`, `drivers`, `driver_by_classname` | Internal registry | Functional but unsuitable as a permanent public contract; `register_driver` is not exported in `__all__` and no symmetric unregister function exists |
|
||||||
|
| `Step.extra`, visibility serialization, MOPA setters | Imported host workflow interfaces | Used by the disabled-output import bridge; exact source files and symbols are pinned before host integration |
|
||||||
| Addon-manager registry cleanup | Host lifecycle implementation | Other registries support addon-owned cleanup, but the driver registry is not part of that mechanism |
|
| Addon-manager registry cleanup | Host lifecycle implementation | Other registries support addon-owned cleanup, but the driver registry is not part of that mechanism |
|
||||||
| BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API |
|
| BalorMK `GalvoController` and `MockConnection` | Application-internal reference/test seam | Valid as a pinned behavioral oracle; not treated as a stable library API |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,18 @@ def _number(element: ET.Element, field: str) -> str | None:
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _layer_id(element: ET.Element) -> str | None:
|
||||||
|
children = element.findall("index")
|
||||||
|
if not children:
|
||||||
|
return None
|
||||||
|
if len(children) != 1 or set(children[0].attrib) != {"Value"}:
|
||||||
|
raise ProjectImportError("ambiguous layer index")
|
||||||
|
raw = children[0].get("Value", "")
|
||||||
|
if not raw.isdecimal():
|
||||||
|
raise ProjectImportError("invalid layer index")
|
||||||
|
return str(int(raw))
|
||||||
|
|
||||||
|
|
||||||
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
|
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
|
||||||
"""Preserve observed XML fields and optionally normalize declared units.
|
"""Preserve observed XML fields and optionally normalize declared units.
|
||||||
|
|
||||||
|
|
@ -51,6 +63,7 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
|
||||||
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
|
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
|
||||||
raise ProjectImportError("unsupported project format")
|
raise ProjectImportError("unsupported project format")
|
||||||
layers = []
|
layers = []
|
||||||
|
layer_ids: set[str] = set()
|
||||||
for setting in root:
|
for setting in root:
|
||||||
if setting.tag not in ("CutSetting", "CutSetting_Img"):
|
if setting.tag not in ("CutSetting", "CutSetting_Img"):
|
||||||
continue
|
continue
|
||||||
|
|
@ -58,6 +71,11 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
|
||||||
expected = ("Image",) if setting.tag == "CutSetting_Img" else ("Cut", "Scan")
|
expected = ("Image",) if setting.tag == "CutSetting_Img" else ("Cut", "Scan")
|
||||||
if kind not in expected:
|
if kind not in expected:
|
||||||
raise ProjectImportError("unsupported layer type")
|
raise ProjectImportError("unsupported layer type")
|
||||||
|
layer_id = _layer_id(setting)
|
||||||
|
if layer_id is not None and layer_id in layer_ids:
|
||||||
|
raise ProjectImportError("duplicate layer index")
|
||||||
|
if layer_id is not None:
|
||||||
|
layer_ids.add(layer_id)
|
||||||
raw = {key: _number(setting, key) for key in ("frequency", "QPulseWidth")}
|
raw = {key: _number(setting, key) for key in ("frequency", "QPulseWidth")}
|
||||||
blockers = []
|
blockers = []
|
||||||
if unit_contract is None:
|
if unit_contract is None:
|
||||||
|
|
@ -78,7 +96,8 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
|
||||||
"frequency_khz": float(Decimal(raw["frequency"]) / 1000),
|
"frequency_khz": float(Decimal(raw["frequency"]) / 1000),
|
||||||
"pulse_width_ns": float(Decimal(raw["QPulseWidth"])),
|
"pulse_width_ns": float(Decimal(raw["QPulseWidth"])),
|
||||||
}
|
}
|
||||||
layers.append({"element": setting.tag, "type": kind, "raw": raw,
|
layers.append({"element": setting.tag, "type": kind,
|
||||||
|
"source_layer_id": layer_id, "raw": raw,
|
||||||
"image_mode": image_mode,
|
"image_mode": image_mode,
|
||||||
"normalized": normalized, "blockers": blockers})
|
"normalized": normalized, "blockers": blockers})
|
||||||
if not layers:
|
if not layers:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Stage MOPA metadata on Rayforge workflow steps with output disabled."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
METADATA_KEY = "pirateray_import"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowImportError(ValueError):
|
||||||
|
"""The imported layer cannot be bound safely to a Rayforge step."""
|
||||||
|
|
||||||
|
|
||||||
|
def _host_integer(value: Any, label: str) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
|
raise WorkflowImportError(f"{label} must be numeric")
|
||||||
|
result = int(value)
|
||||||
|
if result <= 0 or result != value:
|
||||||
|
raise WorkflowImportError(f"{label} must be a positive integer")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def stage_mopa_workflow_steps(
|
||||||
|
imported: Mapping[str, Any],
|
||||||
|
steps_by_layer_id: Mapping[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Bind complete imported MOPA settings while keeping every step disabled.
|
||||||
|
|
||||||
|
The mapping is constructed by the host integration from the source segment's
|
||||||
|
``layer_id`` to the single workflow step created by Rayforge's assembler.
|
||||||
|
This seam never enables a step and never removes an existing blocker.
|
||||||
|
"""
|
||||||
|
if imported.get("encoding_allowed") is not False:
|
||||||
|
raise WorkflowImportError("import must explicitly prohibit encoding")
|
||||||
|
if imported.get("hardware_use_allowed") is not False:
|
||||||
|
raise WorkflowImportError("import must explicitly prohibit hardware use")
|
||||||
|
layers = imported.get("layers")
|
||||||
|
if not isinstance(layers, list):
|
||||||
|
raise WorkflowImportError("imported layers are missing")
|
||||||
|
|
||||||
|
by_id: dict[str, Mapping[str, Any]] = {}
|
||||||
|
for layer in layers:
|
||||||
|
if not isinstance(layer, Mapping):
|
||||||
|
raise WorkflowImportError("imported layer must be an object")
|
||||||
|
layer_id = layer.get("source_layer_id")
|
||||||
|
if layer_id is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(layer_id, str) or layer_id in by_id:
|
||||||
|
raise WorkflowImportError("imported layer identity is ambiguous")
|
||||||
|
by_id[layer_id] = layer
|
||||||
|
if not steps_by_layer_id:
|
||||||
|
raise WorkflowImportError("no Rayforge workflow steps were supplied")
|
||||||
|
|
||||||
|
global_blockers = imported.get("blockers")
|
||||||
|
if not isinstance(global_blockers, list) or not all(
|
||||||
|
isinstance(item, str) and item for item in global_blockers
|
||||||
|
):
|
||||||
|
raise WorkflowImportError("global blockers are missing")
|
||||||
|
|
||||||
|
plans = []
|
||||||
|
step_identities: set[int] = set()
|
||||||
|
for layer_id, step in steps_by_layer_id.items():
|
||||||
|
if not isinstance(layer_id, str):
|
||||||
|
raise WorkflowImportError("Rayforge layer ID must be text")
|
||||||
|
if id(step) in step_identities:
|
||||||
|
raise WorkflowImportError("one Rayforge step cannot represent multiple layers")
|
||||||
|
step_identities.add(id(step))
|
||||||
|
layer = by_id.get(layer_id)
|
||||||
|
if layer is None:
|
||||||
|
raise WorkflowImportError(f"no imported settings for layer {layer_id}")
|
||||||
|
if not isinstance(getattr(step, "extra", None), dict):
|
||||||
|
raise WorkflowImportError("Rayforge step has no serializable extra data")
|
||||||
|
if METADATA_KEY in step.extra:
|
||||||
|
raise WorkflowImportError("Rayforge step already has PirateRay import data")
|
||||||
|
if not hasattr(step, "visible") or not callable(
|
||||||
|
getattr(step, "set_visible", None)
|
||||||
|
):
|
||||||
|
raise WorkflowImportError("Rayforge step has no output visibility gate")
|
||||||
|
normalized = layer.get("normalized")
|
||||||
|
layer_blockers = layer.get("blockers")
|
||||||
|
if not isinstance(layer_blockers, list):
|
||||||
|
raise WorkflowImportError("layer blockers are missing")
|
||||||
|
apply_values = normalized is not None and not layer_blockers
|
||||||
|
frequency = pulse_width = None
|
||||||
|
if apply_values:
|
||||||
|
if not callable(getattr(step, "set_frequency", None)) or not callable(
|
||||||
|
getattr(step, "set_pulse_width", None)
|
||||||
|
):
|
||||||
|
raise WorkflowImportError("Rayforge step has no MOPA setters")
|
||||||
|
frequency = _host_integer(normalized.get("frequency_hz"), "frequency")
|
||||||
|
pulse_width = _host_integer(
|
||||||
|
normalized.get("pulse_width_ns"), "pulse width"
|
||||||
|
)
|
||||||
|
plans.append((layer_id, step, layer, frequency, pulse_width))
|
||||||
|
|
||||||
|
snapshots = [
|
||||||
|
(step, step.visible, deepcopy(step.extra), getattr(step, "frequency", None),
|
||||||
|
getattr(step, "pulse_width", None))
|
||||||
|
for _layer_id, step, _layer, _frequency, _pulse_width in plans
|
||||||
|
]
|
||||||
|
staged = []
|
||||||
|
try:
|
||||||
|
for layer_id, step, layer, frequency, pulse_width in plans:
|
||||||
|
step.set_visible(False)
|
||||||
|
blockers = list(global_blockers) + list(layer["blockers"])
|
||||||
|
if frequency is not None and pulse_width is not None:
|
||||||
|
step.set_frequency(frequency)
|
||||||
|
step.set_pulse_width(pulse_width)
|
||||||
|
step.extra[METADATA_KEY] = {
|
||||||
|
"status": "staged-review-required",
|
||||||
|
"source_layer_id": layer_id,
|
||||||
|
"raw": deepcopy(layer.get("raw")),
|
||||||
|
"normalized": deepcopy(layer.get("normalized")),
|
||||||
|
"blockers": blockers,
|
||||||
|
"encoding_allowed": False,
|
||||||
|
"hardware_use_allowed": False,
|
||||||
|
}
|
||||||
|
staged.append({
|
||||||
|
"source_layer_id": layer_id,
|
||||||
|
"parameters_applied": frequency is not None,
|
||||||
|
"output_enabled": False,
|
||||||
|
"blockers": blockers,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
for step, visible, extra, frequency, pulse_width in snapshots:
|
||||||
|
step.set_visible(visible)
|
||||||
|
step.extra.clear()
|
||||||
|
step.extra.update(extra)
|
||||||
|
if frequency is not None:
|
||||||
|
step.frequency = frequency
|
||||||
|
if pulse_width is not None:
|
||||||
|
step.pulse_width = pulse_width
|
||||||
|
raise
|
||||||
|
return {"steps": staged, "encoding_allowed": False,
|
||||||
|
"hardware_use_allowed": False, "transport": None}
|
||||||
1
tests/fixtures/rayforge-addon-spike.json
vendored
1
tests/fixtures/rayforge-addon-spike.json
vendored
|
|
@ -23,6 +23,7 @@
|
||||||
"pirate_ray_jcz/rayforge_compat.py",
|
"pirate_ray_jcz/rayforge_compat.py",
|
||||||
"pirate_ray_jcz/recipe_metadata.py",
|
"pirate_ray_jcz/recipe_metadata.py",
|
||||||
"pirate_ray_jcz/worker.py",
|
"pirate_ray_jcz/worker.py",
|
||||||
|
"pirate_ray_jcz/workflow_import.py",
|
||||||
"rayforge-addon.yaml"
|
"rayforge-addon.yaml"
|
||||||
],
|
],
|
||||||
"registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup"
|
"registration_seam": "fail-closed API 23 compatibility adapter + on_unload cleanup"
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ from pirate_ray_jcz.project_import import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def project(fields, tag="CutSetting", kind="Scan"):
|
def project(fields, tag="CutSetting", kind="Scan", layer_id="0"):
|
||||||
return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
|
return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
|
||||||
f'{fields}</{tag}></LightBurnProject>').encode()
|
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
|
||||||
|
|
||||||
|
|
||||||
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
|
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
|
||||||
|
|
@ -25,6 +25,7 @@ class ProjectMopaTests(unittest.TestCase):
|
||||||
self.assertFalse(result["encoding_allowed"])
|
self.assertFalse(result["encoding_allowed"])
|
||||||
self.assertFalse(result["hardware_use_allowed"])
|
self.assertFalse(result["hardware_use_allowed"])
|
||||||
self.assertIsNone(result["transport"])
|
self.assertIsNone(result["transport"])
|
||||||
|
self.assertEqual(result["layers"][0]["source_layer_id"], "0")
|
||||||
|
|
||||||
def test_units_are_never_inferred_from_magnitude(self):
|
def test_units_are_never_inferred_from_magnitude(self):
|
||||||
result = import_project_mopa(project(FIELDS))
|
result = import_project_mopa(project(FIELDS))
|
||||||
|
|
@ -69,10 +70,20 @@ class ProjectMopaTests(unittest.TestCase):
|
||||||
with self.assertRaises(ProjectImportError):
|
with self.assertRaises(ProjectImportError):
|
||||||
import_project_mopa(data)
|
import_project_mopa(data)
|
||||||
|
|
||||||
|
def test_layer_indices_are_normalized_and_must_be_unique(self):
|
||||||
|
layer = import_project_mopa(project(FIELDS, layer_id="007"))["layers"][0]
|
||||||
|
self.assertEqual(layer["source_layer_id"], "7")
|
||||||
|
duplicate = ('<LightBurnProject FormatVersion="1">'
|
||||||
|
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||||
|
'<CutSetting type="Scan"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||||
|
'</LightBurnProject>').encode()
|
||||||
|
with self.assertRaisesRegex(ProjectImportError, "duplicate layer index"):
|
||||||
|
import_project_mopa(duplicate)
|
||||||
|
|
||||||
def test_multiple_layers_preserve_order_and_missing_pulse(self):
|
def test_multiple_layers_preserve_order_and_missing_pulse(self):
|
||||||
data = ('<LightBurnProject FormatVersion="1">'
|
data = ('<LightBurnProject FormatVersion="1">'
|
||||||
'<CutSetting type="Cut">'+FIELDS+'</CutSetting>'
|
'<CutSetting type="Cut"><index Value="0"/>'+FIELDS+'</CutSetting>'
|
||||||
'<CutSetting_Img type="Image"><frequency Value="125000"/>'
|
'<CutSetting_Img type="Image"><index Value="1"/><frequency Value="125000"/>'
|
||||||
'</CutSetting_Img></LightBurnProject>').encode()
|
'</CutSetting_Img></LightBurnProject>').encode()
|
||||||
layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"]
|
layers = import_project_mopa(data, unit_contract=UNIT_CONTRACT)["layers"]
|
||||||
self.assertEqual([x["type"] for x in layers], ["Cut", "Image"])
|
self.assertEqual([x["type"] for x in layers], ["Cut", "Image"])
|
||||||
|
|
|
||||||
113
tests/test_lightburn_workflow_import.py
Normal file
113
tests/test_lightburn_workflow_import.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[1] / "spikes/rayforge-jcz-no-device-addon"))
|
||||||
|
from pirate_ray_jcz.project_import import UNIT_CONTRACT, import_project_mopa
|
||||||
|
from pirate_ray_jcz.workflow_import import (
|
||||||
|
METADATA_KEY, WorkflowImportError, stage_mopa_workflow_steps,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Step:
|
||||||
|
def __init__(self, fail=False):
|
||||||
|
self.visible = True
|
||||||
|
self.frequency = 1
|
||||||
|
self.pulse_width = 2
|
||||||
|
self.extra = {"preserved": True}
|
||||||
|
self.fail = fail
|
||||||
|
|
||||||
|
def set_frequency(self, value):
|
||||||
|
self.frequency = value
|
||||||
|
|
||||||
|
def set_visible(self, value):
|
||||||
|
self.visible = value
|
||||||
|
|
||||||
|
def set_pulse_width(self, value):
|
||||||
|
if self.fail:
|
||||||
|
raise ValueError("synthetic setter failure")
|
||||||
|
self.pulse_width = value
|
||||||
|
|
||||||
|
|
||||||
|
def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"):
|
||||||
|
data = (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
|
||||||
|
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
|
||||||
|
return import_project_mopa(data, unit_contract=UNIT_CONTRACT)
|
||||||
|
|
||||||
|
|
||||||
|
FIELDS = '<frequency Value="125000"/><QPulseWidth Value="25"/>'
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowImportTests(unittest.TestCase):
|
||||||
|
def test_complete_vector_settings_are_staged_but_output_is_disabled(self):
|
||||||
|
step = Step()
|
||||||
|
result = stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
|
||||||
|
self.assertEqual((step.frequency, step.pulse_width), (125000, 25))
|
||||||
|
self.assertFalse(step.visible)
|
||||||
|
self.assertFalse(result["encoding_allowed"])
|
||||||
|
self.assertIsNone(result["transport"])
|
||||||
|
gate = step.extra[METADATA_KEY]
|
||||||
|
self.assertEqual(gate["status"], "staged-review-required")
|
||||||
|
self.assertFalse(gate["encoding_allowed"])
|
||||||
|
self.assertTrue(gate["blockers"])
|
||||||
|
|
||||||
|
def test_missing_pulse_and_image_mode_do_not_apply_parameters(self):
|
||||||
|
for result in (
|
||||||
|
imported('<frequency Value="125000"/>'),
|
||||||
|
imported(FIELDS + '<ditherMode Value="3dslice"/>', kind="Image", tag="CutSetting_Img"),
|
||||||
|
):
|
||||||
|
with self.subTest(result=result):
|
||||||
|
step = Step()
|
||||||
|
staged = stage_mopa_workflow_steps(result, {"3": step})
|
||||||
|
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
|
||||||
|
self.assertFalse(step.visible)
|
||||||
|
self.assertFalse(staged["steps"][0]["parameters_applied"])
|
||||||
|
|
||||||
|
def test_layer_identity_must_match(self):
|
||||||
|
with self.assertRaisesRegex(WorkflowImportError, "no imported settings"):
|
||||||
|
stage_mopa_workflow_steps(imported(FIELDS), {"4": Step()})
|
||||||
|
|
||||||
|
def test_existing_metadata_and_reused_step_are_rejected(self):
|
||||||
|
step = Step()
|
||||||
|
step.extra[METADATA_KEY] = {"status": "existing"}
|
||||||
|
with self.assertRaisesRegex(WorkflowImportError, "already has"):
|
||||||
|
stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
|
||||||
|
|
||||||
|
two_layers = import_project_mopa(
|
||||||
|
('<LightBurnProject FormatVersion="1">'
|
||||||
|
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||||
|
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||||
|
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||||
|
reused = Step()
|
||||||
|
with self.assertRaisesRegex(WorkflowImportError, "multiple layers"):
|
||||||
|
stage_mopa_workflow_steps(two_layers, {"1": reused, "2": reused})
|
||||||
|
|
||||||
|
def test_preflight_failure_does_not_mutate_any_step(self):
|
||||||
|
result = import_project_mopa(
|
||||||
|
('<LightBurnProject FormatVersion="1">'
|
||||||
|
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||||
|
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||||
|
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||||
|
first = Step()
|
||||||
|
with self.assertRaisesRegex(WorkflowImportError, "no serializable extra"):
|
||||||
|
stage_mopa_workflow_steps(result, {"1": first, "2": object()})
|
||||||
|
self.assertTrue(first.visible)
|
||||||
|
self.assertEqual((first.frequency, first.pulse_width), (1, 2))
|
||||||
|
|
||||||
|
def test_setter_failure_rolls_back_every_step(self):
|
||||||
|
result = import_project_mopa(
|
||||||
|
('<LightBurnProject FormatVersion="1">'
|
||||||
|
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||||
|
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||||
|
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||||
|
first, second = Step(), Step(fail=True)
|
||||||
|
with self.assertRaisesRegex(ValueError, "synthetic setter failure"):
|
||||||
|
stage_mopa_workflow_steps(result, {"1": first, "2": second})
|
||||||
|
for step in (first, second):
|
||||||
|
self.assertTrue(step.visible)
|
||||||
|
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
|
||||||
|
self.assertEqual(step.extra, {"preserved": True})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -38,6 +38,18 @@
|
||||||
"sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b",
|
"sha256": "6f734a0246f35a7f18d8e4fa60b38f66193d9a5ece0982829cc269133e6ddd0b",
|
||||||
"stability": "host-lifecycle-implementation",
|
"stability": "host-lifecycle-implementation",
|
||||||
"required_symbols": ["AddonManager", "AddonManager._unregister_addon_items"]
|
"required_symbols": ["AddonManager", "AddonManager._unregister_addon_items"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "rayforge/core/step.py",
|
||||||
|
"sha256": "4d25da9e4fb9f854562bfd33d238db3660c3056eeadfaa95c8669c6770d504f4",
|
||||||
|
"stability": "imported-host-workflow-interface",
|
||||||
|
"required_symbols": ["Step", "Step.set_visible", "Step.to_dict", "Step.from_dict"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "rayforge/builtin_addons/rayforge-addon-laser/laser_essentials/steps/laser_step.py",
|
||||||
|
"sha256": "59c94fec6861edc94383db856bba0c335e0e7c4d16a878e7c2a2c62b657e06c5",
|
||||||
|
"stability": "imported-host-workflow-interface",
|
||||||
|
"required_symbols": ["LaserStep", "LaserStep.set_frequency", "LaserStep.set_pulse_width", "LaserStep.to_dict", "LaserStep.from_dict"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"purpose": "Pinned host application for offline reuse and JCZ addon validation"
|
"purpose": "Pinned host application for offline reuse and JCZ addon validation"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue