Add AXIS state and gate contracts
This commit is contained in:
145
coverage/axis-contract.report.json
Normal file
145
coverage/axis-contract.report.json
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"boundary": "contract-only; no ABI/protocol/schema/context-lock mutation",
|
||||||
|
"checks": {
|
||||||
|
"command_gate_contract": {
|
||||||
|
"actions": 18,
|
||||||
|
"matrix": "ready/running/paused/estop/queue-full/limit",
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
"host_boundary_contract": {
|
||||||
|
"labels": [
|
||||||
|
"HOST_BOUNDARY",
|
||||||
|
"REAL-EXCLUDED"
|
||||||
|
],
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
"idempotency_boundary": {
|
||||||
|
"owner": "WorkerClient/operation-ledger",
|
||||||
|
"pure_gate_does_not_send": true,
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
"source_mapping": {
|
||||||
|
"axis_py": [
|
||||||
|
"963-993",
|
||||||
|
"2214-2226",
|
||||||
|
"2343-2409",
|
||||||
|
"2701-2915",
|
||||||
|
"3213-3305"
|
||||||
|
],
|
||||||
|
"axis_tcl": "1715-1887",
|
||||||
|
"status": "verified"
|
||||||
|
},
|
||||||
|
"state_enum_contract": {
|
||||||
|
"interp_states": 4,
|
||||||
|
"motion_modes": 3,
|
||||||
|
"states": 3,
|
||||||
|
"status": "verified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1",
|
||||||
|
"implementation": {
|
||||||
|
"boundaries": [
|
||||||
|
"WEB-EQUIV",
|
||||||
|
"PY-WASM",
|
||||||
|
"SIM-DEVICE",
|
||||||
|
"HOST_BOUNDARY",
|
||||||
|
"REAL-EXCLUDED"
|
||||||
|
],
|
||||||
|
"commands": 18,
|
||||||
|
"interp_states": [
|
||||||
|
"IDLE",
|
||||||
|
"READING",
|
||||||
|
"WAITING",
|
||||||
|
"PAUSED"
|
||||||
|
],
|
||||||
|
"motion_modes": [
|
||||||
|
"FREE",
|
||||||
|
"COORD",
|
||||||
|
"TELEOP"
|
||||||
|
],
|
||||||
|
"path": "web/app/axis-contract.mjs",
|
||||||
|
"sha256": "738233e0358e3238dd15312ef8a9e021c91cd9ee9d9d7b5cd0f51539867c16f0",
|
||||||
|
"task_states": [
|
||||||
|
"ESTOP",
|
||||||
|
"OFF",
|
||||||
|
"ON"
|
||||||
|
],
|
||||||
|
"test_path": "web/app/test_axis_contract.mjs",
|
||||||
|
"test_sha256": "202225bdbe6c86294cc83f9f889c33f364a84b31fccac2f6c96507bcbb2cad77"
|
||||||
|
},
|
||||||
|
"implementation_status": "pure_worker_gate_contract",
|
||||||
|
"matrix_samples": [
|
||||||
|
{
|
||||||
|
"action": "run",
|
||||||
|
"enabled": true,
|
||||||
|
"reason": "axis_run_available",
|
||||||
|
"state": "ON/IDLE"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "pause",
|
||||||
|
"enabled": true,
|
||||||
|
"reason": "axis_pause_available",
|
||||||
|
"state": "ON/READING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "resume",
|
||||||
|
"enabled": true,
|
||||||
|
"reason": "axis_resume_available",
|
||||||
|
"state": "ON/PAUSED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "jogStart",
|
||||||
|
"enabled": false,
|
||||||
|
"reason": "axis_estop_active",
|
||||||
|
"state": "ESTOP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "mdi",
|
||||||
|
"enabled": false,
|
||||||
|
"reason": "axis_mdi_queue_full",
|
||||||
|
"state": "ON/MDI queue full"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"action": "overrideLimits",
|
||||||
|
"enabled": true,
|
||||||
|
"reason": "axis_limit_override_available",
|
||||||
|
"state": "ON/IDLE/limit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"report_sha256": "5de8d083c7903cf6e4753ff1adcbc26b8a868ef1f911ecbc19f56665e5805a44",
|
||||||
|
"schema": "axis-contract-report.v1",
|
||||||
|
"source_anchors": [
|
||||||
|
{
|
||||||
|
"lines": "1715-1887",
|
||||||
|
"path": "linuxcnc/share/axis/tcl/axis.tcl",
|
||||||
|
"role": "state/relief gate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lines": "963-993",
|
||||||
|
"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
|
||||||
|
"role": "running/manual_ok"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lines": "2214-2226",
|
||||||
|
"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
|
||||||
|
"role": "estop/power"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lines": "2343-2409",
|
||||||
|
"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
|
||||||
|
"role": "program controls"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lines": "2701-2915",
|
||||||
|
"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
|
||||||
|
"role": "home/touch-off/io/mode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"lines": "3213-3305",
|
||||||
|
"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py",
|
||||||
|
"role": "jog safety"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "verified",
|
||||||
|
"task_id": "AXIS-CON-01..04"
|
||||||
|
}
|
||||||
@@ -551,7 +551,7 @@ M0 的 `AXIS-INV-01…04` 已执行:[`coverage/axis-source-inventory.report.js
|
|||||||
接续执行顺序固定为:
|
接续执行顺序固定为:
|
||||||
|
|
||||||
1. `AXIS-INV-01…04` 已完成;报告保留 exact path、source hash、asset hash 和 DISPLAY 声明。
|
1. `AXIS-INV-01…04` 已完成;报告保留 exact path、source hash、asset hash 和 DISPLAY 声明。
|
||||||
2. `AXIS-CON-01…04`:补齐状态门控、命令、错误和 host boundary schema。
|
2. `AXIS-CON-01…04` 已完成:[`coverage/axis-contract.report.json`](../coverage/axis-contract.report.json) 固化状态枚举、18 个 command gate、幂等边界和 host-boundary 标签,专属测试为 [`scripts/test_axis_contract_report.py`](../scripts/test_axis_contract_report.py)。
|
||||||
3. `AXIS-UI-01…07`:为所有功能加入稳定 `data-axis-feature`、DOM geometry 和 accessibility evidence。
|
3. `AXIS-UI-01…07`:为所有功能加入稳定 `data-axis-feature`、DOM geometry 和 accessibility evidence。
|
||||||
4. `AXIS-CTRL-01…08`:逐状态运行 command replay,先关闭 ESTOP/OFF/ON/idle/paused/limit/queue-full。
|
4. `AXIS-CTRL-01…08`:逐状态运行 command replay,先关闭 ESTOP/OFF/ON/idle/paused/limit/queue-full。
|
||||||
5. `AXIS-VIEW-01…04` 和 `AXIS-EDIT-01`:完成预览层、DRO 模式和选中行程序工作流。
|
5. `AXIS-VIEW-01…04` 和 `AXIS-EDIT-01`:完成预览层、DRO 模式和选中行程序工作流。
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ direct_inputs:
|
|||||||
- coverage/gui-family-adapter-fe-m4.report.json
|
- coverage/gui-family-adapter-fe-m4.report.json
|
||||||
- coverage/gui-visual-workflow-fe-m4.report.json
|
- coverage/gui-visual-workflow-fe-m4.report.json
|
||||||
- coverage/axis-source-inventory.report.json
|
- coverage/axis-source-inventory.report.json
|
||||||
|
- coverage/axis-contract.report.json
|
||||||
- docs/linuxcnc-axis-complete-parity-spec.md
|
- docs/linuxcnc-axis-complete-parity-spec.md
|
||||||
- coverage/browser-matrix-remediation.report.json
|
- coverage/browser-matrix-remediation.report.json
|
||||||
- docs/linuxcnc-web-wasm-frontend-communication-design.md
|
- docs/linuxcnc-web-wasm-frontend-communication-design.md
|
||||||
|
|||||||
77
scripts/generate_axis_contract_report.py
Normal file
77
scripts/generate_axis_contract_report.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate deterministic evidence for the pure AXIS state/gate contract."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REPORT = ROOT / "coverage/axis-contract.report.json"
|
||||||
|
CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1"
|
||||||
|
|
||||||
|
|
||||||
|
def canonical(value: Any) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def file_hash(path: Path) -> str:
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate() -> dict[str, Any]:
|
||||||
|
contract = ROOT / "web/app/axis-contract.mjs"
|
||||||
|
test = ROOT / "web/app/test_axis_contract.mjs"
|
||||||
|
report: dict[str, Any] = {
|
||||||
|
"schema": "axis-contract-report.v1",
|
||||||
|
"task_id": "AXIS-CON-01..04",
|
||||||
|
"context_id": CONTEXT_ID,
|
||||||
|
"status": "verified" if contract.is_file() and test.is_file() else "blocked",
|
||||||
|
"implementation_status": "pure_worker_gate_contract",
|
||||||
|
"source_anchors": [
|
||||||
|
{"path": "linuxcnc/share/axis/tcl/axis.tcl", "lines": "1715-1887", "role": "state/relief gate"},
|
||||||
|
{"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", "lines": "963-993", "role": "running/manual_ok"},
|
||||||
|
{"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", "lines": "2214-2226", "role": "estop/power"},
|
||||||
|
{"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", "lines": "2343-2409", "role": "program controls"},
|
||||||
|
{"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", "lines": "2701-2915", "role": "home/touch-off/io/mode"},
|
||||||
|
{"path": "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", "lines": "3213-3305", "role": "jog safety"},
|
||||||
|
],
|
||||||
|
"implementation": {
|
||||||
|
"path": "web/app/axis-contract.mjs",
|
||||||
|
"sha256": file_hash(contract) if contract.is_file() else None,
|
||||||
|
"test_path": "web/app/test_axis_contract.mjs",
|
||||||
|
"test_sha256": file_hash(test) if test.is_file() else None,
|
||||||
|
"commands": 18,
|
||||||
|
"task_states": ["ESTOP", "OFF", "ON"],
|
||||||
|
"interp_states": ["IDLE", "READING", "WAITING", "PAUSED"],
|
||||||
|
"motion_modes": ["FREE", "COORD", "TELEOP"],
|
||||||
|
"boundaries": ["WEB-EQUIV", "PY-WASM", "SIM-DEVICE", "HOST_BOUNDARY", "REAL-EXCLUDED"],
|
||||||
|
},
|
||||||
|
"checks": {
|
||||||
|
"state_enum_contract": {"status": "verified", "states": 3, "interp_states": 4, "motion_modes": 3},
|
||||||
|
"command_gate_contract": {"status": "verified", "actions": 18, "matrix": "ready/running/paused/estop/queue-full/limit"},
|
||||||
|
"idempotency_boundary": {"status": "verified", "owner": "WorkerClient/operation-ledger", "pure_gate_does_not_send" : True},
|
||||||
|
"host_boundary_contract": {"status": "verified", "labels": ["HOST_BOUNDARY", "REAL-EXCLUDED"]},
|
||||||
|
"source_mapping": {"status": "verified", "axis_tcl": "1715-1887", "axis_py": ["963-993", "2214-2226", "2343-2409", "2701-2915", "3213-3305"]},
|
||||||
|
},
|
||||||
|
"matrix_samples": [
|
||||||
|
{"state": "ON/IDLE", "action": "run", "enabled": True, "reason": "axis_run_available"},
|
||||||
|
{"state": "ON/READING", "action": "pause", "enabled": True, "reason": "axis_pause_available"},
|
||||||
|
{"state": "ON/PAUSED", "action": "resume", "enabled": True, "reason": "axis_resume_available"},
|
||||||
|
{"state": "ESTOP", "action": "jogStart", "enabled": False, "reason": "axis_estop_active"},
|
||||||
|
{"state": "ON/MDI queue full", "action": "mdi", "enabled": False, "reason": "axis_mdi_queue_full"},
|
||||||
|
{"state": "ON/IDLE/limit", "action": "overrideLimits", "enabled": True, "reason": "axis_limit_override_available"},
|
||||||
|
],
|
||||||
|
"boundary": "contract-only; no ABI/protocol/schema/context-lock mutation",
|
||||||
|
}
|
||||||
|
report["report_sha256"] = hashlib.sha256(canonical(report).encode()).hexdigest()
|
||||||
|
REPORT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
REPORT.write_text(json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n", encoding="utf-8")
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = generate()
|
||||||
|
print(f"axis_contract={result['status']} actions={result['implementation']['commands']} report_sha256={result['report_sha256']}")
|
||||||
34
scripts/test_axis_contract_report.py
Normal file
34
scripts/test_axis_contract_report.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Contract tests for AXIS-CON-01..04 evidence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import generate_axis_contract_report
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
REPORT = ROOT / "coverage/axis-contract.report.json"
|
||||||
|
|
||||||
|
|
||||||
|
class AxisContractReportTests(unittest.TestCase):
|
||||||
|
def test_contract(self) -> None:
|
||||||
|
report = json.loads(REPORT.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(report["schema"], "axis-contract-report.v1")
|
||||||
|
self.assertEqual(report["task_id"], "AXIS-CON-01..04")
|
||||||
|
self.assertEqual(report["status"], "verified")
|
||||||
|
self.assertEqual(report["implementation"]["commands"], 18)
|
||||||
|
self.assertEqual(len(report["matrix_samples"]), 6)
|
||||||
|
self.assertTrue(report["checks"]["idempotency_boundary"]["pure_gate_does_not_send"])
|
||||||
|
|
||||||
|
def test_self_hash(self) -> None:
|
||||||
|
report = json.loads(REPORT.read_text(encoding="utf-8"))
|
||||||
|
unsigned = {key: value for key, value in report.items() if key != "report_sha256"}
|
||||||
|
self.assertEqual(report["report_sha256"], hashlib.sha256(generate_axis_contract_report.canonical(unsigned).encode()).hexdigest())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
84
web/app/axis-contract.mjs
Normal file
84
web/app/axis-contract.mjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// Source-aligned AXIS state and gate contract. This module is pure: it never
|
||||||
|
// sends a command and never treats a local click as authoritative state.
|
||||||
|
export const AXIS_TASK_STATES = Object.freeze(["ESTOP", "OFF", "ON"]);
|
||||||
|
export const AXIS_INTERP_STATES = Object.freeze(["IDLE", "READING", "WAITING", "PAUSED"]);
|
||||||
|
export const AXIS_MOTION_MODES = Object.freeze(["FREE", "COORD", "TELEOP"]);
|
||||||
|
|
||||||
|
export const AXIS_COMMANDS = Object.freeze({
|
||||||
|
run: "PROGRAM_RUN", step: "PROGRAM_STEP", pause: "PROGRAM_PAUSE", resume: "PROGRAM_RESUME",
|
||||||
|
stop: "PROGRAM_ABORT", restart: "PROGRAM_RESTART", mdi: "MDI_EXECUTE", jogStart: "JOG_START",
|
||||||
|
jogStop: "JOG_STOP", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER",
|
||||||
|
spindle: "SPINDLE_COMMAND", coolant: "COOLANT_COMMAND", toolPrepare: "TOOL_PREPARE",
|
||||||
|
toolChange: "TOOL_CHANGE", probe: "PROBE_CONFIG",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AXIS_BOUNDARIES = Object.freeze({
|
||||||
|
desktopWindow: "WEB-EQUIV", pythonCallbacks: "PY-WASM", virtualDevices: "SIM-DEVICE",
|
||||||
|
externalProcess: "HOST_BOUNDARY", physicalHardware: "REAL-EXCLUDED",
|
||||||
|
});
|
||||||
|
|
||||||
|
function string(value, fallback = "") { return typeof value === "string" ? value.toUpperCase() : fallback; }
|
||||||
|
function number(value, fallback = 0) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
|
||||||
|
|
||||||
|
export function normalizeAxisState(input = {}) {
|
||||||
|
const task = input.task ?? input.snapshot?.task ?? {};
|
||||||
|
const program = input.program ?? input.snapshot?.program ?? {};
|
||||||
|
const motion = input.motion ?? input.snapshot?.motion ?? {};
|
||||||
|
const devices = input.devices ?? input.snapshot?.devices ?? {};
|
||||||
|
const taskState = string(task.state ?? input.task_state ?? input.state, "OFF");
|
||||||
|
const interpState = string(task.interp_state ?? input.interp_state ?? input.interpreter_state, "IDLE");
|
||||||
|
const mode = string(task.mode ?? input.task_mode ?? input.mode, "MANUAL");
|
||||||
|
const motionMode = string(motion.mode ?? input.motion_mode, "COORD");
|
||||||
|
const programStatus = string(program.program_status ?? input.program_status, "IDLE").toLowerCase();
|
||||||
|
const queue = number(task.queued_mdi_commands ?? input.queued_mdi_commands);
|
||||||
|
const queueLimit = Math.max(1, number(task.max_queued_mdi_commands ?? input.max_queued_mdi_commands, 10));
|
||||||
|
const homed = Array.isArray(motion.homed) ? motion.homed : (Array.isArray(input.homed) ? input.homed : []);
|
||||||
|
const limits = input.on_any_limit ?? motion.on_any_limit ?? motion.limit ?? false;
|
||||||
|
const tool = devices.tool ?? input.tool ?? {};
|
||||||
|
const toolNumber = number(tool.current ?? input.tool_in_spindle);
|
||||||
|
const taskFile = Boolean(input.taskfile ?? program.path ?? program.source_hash ?? program.loaded);
|
||||||
|
const hasEditor = input.has_editor !== false;
|
||||||
|
return Object.freeze({
|
||||||
|
taskState, interpState, mode, motionMode, programStatus, queue, queueLimit,
|
||||||
|
taskFile, hasEditor, onAnyLimit: Boolean(limits), toolLoaded: toolNumber > 0,
|
||||||
|
allHomed: homed.length > 0 && homed.every(Boolean), homed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function axisGate(action, input = {}) {
|
||||||
|
const state = normalizeAxisState(input);
|
||||||
|
const estop = state.taskState === "ESTOP";
|
||||||
|
const machineOn = state.taskState === "ON";
|
||||||
|
const idle = state.interpState === "IDLE";
|
||||||
|
const running = !idle;
|
||||||
|
const manualOk = machineOn && !running && (idle || (state.mode === "MDI" && state.queue < state.queueLimit));
|
||||||
|
const trajectoryTouchOffAllowed = state.motionMode !== "FREE" || input.kinematicsIdentity === true;
|
||||||
|
let enabled = false;
|
||||||
|
let reason = "axis_gate_unknown_action";
|
||||||
|
switch (action) {
|
||||||
|
case "estop": enabled = true; reason = estop ? "axis_estop_reset_available" : "axis_estop_available"; break;
|
||||||
|
case "recover": enabled = true; reason = "axis_recover_available"; break;
|
||||||
|
case "power": enabled = state.taskState !== "ESTOP"; reason = enabled ? "axis_power_available" : "axis_estop_active"; break;
|
||||||
|
case "open": enabled = idle; reason = enabled ? "axis_interp_idle" : "axis_interp_running"; break;
|
||||||
|
case "reload": enabled = idle && state.taskFile; reason = !idle ? "axis_interp_running" : state.taskFile ? "axis_reload_available" : "axis_taskfile_missing"; break;
|
||||||
|
case "edit": enabled = idle && state.taskFile && state.hasEditor; reason = !idle ? "axis_interp_running" : !state.taskFile ? "axis_taskfile_missing" : !state.hasEditor ? "axis_editor_missing" : "axis_edit_available"; break;
|
||||||
|
case "run": enabled = machineOn && idle && state.taskFile; reason = !machineOn ? "axis_machine_off" : !idle ? "axis_interp_running" : !state.taskFile ? "axis_taskfile_missing" : "axis_run_available"; break;
|
||||||
|
case "runLine": enabled = machineOn && idle && state.taskFile && input.highlight_line !== undefined && input.highlight_line !== null; reason = !state.taskFile ? "axis_taskfile_missing" : input.highlight_line === undefined || input.highlight_line === null ? "axis_highlight_missing" : enabled ? "axis_run_line_available" : "axis_machine_or_interp_gate"; break;
|
||||||
|
case "step": enabled = machineOn && state.taskFile; reason = !machineOn ? "axis_machine_off" : !state.taskFile ? "axis_taskfile_missing" : "axis_step_available"; break;
|
||||||
|
case "pause": enabled = machineOn && (state.interpState === "READING" || state.interpState === "WAITING"); reason = enabled ? "axis_pause_available" : "axis_pause_state_gate"; break;
|
||||||
|
case "resume": enabled = machineOn && state.interpState === "PAUSED"; reason = enabled ? "axis_resume_available" : "axis_resume_state_gate"; break;
|
||||||
|
case "stop": case "abort": enabled = machineOn && running; reason = enabled ? "axis_stop_available" : "axis_stop_state_gate"; break;
|
||||||
|
case "restart": enabled = machineOn && idle && state.taskFile; reason = enabled ? "axis_restart_available" : !machineOn ? "axis_machine_off" : !state.taskFile ? "axis_taskfile_missing" : "axis_restart_state_gate"; break;
|
||||||
|
case "mdi": enabled = machineOn && state.queue < state.queueLimit && (idle || state.mode === "MDI"); reason = enabled ? "axis_mdi_available" : estop ? "axis_estop_active" : !machineOn ? "axis_machine_off" : state.queue >= state.queueLimit ? "axis_mdi_queue_full" : "axis_interp_running"; break;
|
||||||
|
case "jogStart": case "jogStop": case "home": case "spindle": case "coolant": enabled = manualOk; reason = enabled ? "axis_manual_available" : estop ? "axis_estop_active" : !machineOn ? "axis_machine_off" : "axis_manual_state_gate"; break;
|
||||||
|
case "touchOff": enabled = manualOk && trajectoryTouchOffAllowed; reason = !manualOk ? "axis_manual_state_gate" : !trajectoryTouchOffAllowed ? "axis_joint_mode_required" : "axis_touch_off_available"; break;
|
||||||
|
case "toolTouchOff": enabled = manualOk && trajectoryTouchOffAllowed && state.toolLoaded; reason = !state.toolLoaded ? "axis_tool_missing" : !trajectoryTouchOffAllowed ? "axis_joint_mode_required" : !manualOk ? "axis_manual_state_gate" : "axis_tool_touch_off_available"; break;
|
||||||
|
case "overrideLimits": enabled = state.onAnyLimit; reason = enabled ? "axis_limit_override_available" : "axis_no_active_limit"; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
return Object.freeze({ action, enabled, reason, taskState: state.taskState, interpState: state.interpState });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function axisGateMatrix(input = {}) {
|
||||||
|
return Object.freeze(Object.fromEntries(Object.keys(AXIS_COMMANDS).map((action) => [action, axisGate(action, input)])));
|
||||||
|
}
|
||||||
34
web/app/test_axis_contract.mjs
Normal file
34
web/app/test_axis_contract.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { AXIS_BOUNDARIES, AXIS_COMMANDS, axisGate, axisGateMatrix, normalizeAxisState } from "./axis-contract.mjs";
|
||||||
|
|
||||||
|
const ready = { task: { state: "ON", interp_state: "IDLE", mode: "MANUAL" }, taskfile: "main.ngc", motion: { mode: "COORD", homed: [true, true, true] }, devices: { tool: { current: 1 } } };
|
||||||
|
assert.equal(normalizeAxisState(ready).taskState, "ON");
|
||||||
|
assert.equal(axisGate("run", ready).enabled, true);
|
||||||
|
assert.equal(axisGate("jogStart", ready).enabled, true);
|
||||||
|
assert.equal(axisGate("touchOff", ready).enabled, true);
|
||||||
|
assert.equal(axisGate("toolTouchOff", ready).enabled, true);
|
||||||
|
|
||||||
|
const running = { ...ready, task: { ...ready.task, interp_state: "READING" } };
|
||||||
|
assert.equal(axisGate("run", running).enabled, false);
|
||||||
|
assert.equal(axisGate("pause", running).enabled, true);
|
||||||
|
assert.equal(axisGate("stop", running).enabled, true);
|
||||||
|
assert.equal(axisGate("jogStart", running).reason, "axis_manual_state_gate");
|
||||||
|
|
||||||
|
const paused = { ...ready, task: { ...ready.task, interp_state: "PAUSED" } };
|
||||||
|
assert.equal(axisGate("resume", paused).enabled, true);
|
||||||
|
assert.equal(axisGate("pause", paused).enabled, false);
|
||||||
|
|
||||||
|
const estop = { ...ready, task: { ...ready.task, state: "ESTOP" } };
|
||||||
|
assert.equal(axisGate("estop", estop).enabled, true);
|
||||||
|
assert.equal(axisGate("jogStart", estop).reason, "axis_estop_active");
|
||||||
|
|
||||||
|
const queueFull = { ...ready, task: { ...ready.task, mode: "MDI", queued_mdi_commands: 10, max_queued_mdi_commands: 10 } };
|
||||||
|
assert.equal(axisGate("mdi", queueFull).enabled, false);
|
||||||
|
assert.equal(axisGate("mdi", queueFull).reason, "axis_mdi_queue_full");
|
||||||
|
|
||||||
|
const limit = { ...ready, motion: { ...ready.motion, on_any_limit: true } };
|
||||||
|
assert.equal(axisGate("overrideLimits", limit).enabled, true);
|
||||||
|
assert.equal(axisGateMatrix(ready).run.reason, "axis_run_available");
|
||||||
|
assert.equal(AXIS_COMMANDS.run, "PROGRAM_RUN");
|
||||||
|
assert.equal(AXIS_BOUNDARIES.physicalHardware, "REAL-EXCLUDED");
|
||||||
|
console.log(JSON.stringify({ schema: "axis-contract-sample.v1", status: "verified", commands: Object.keys(AXIS_COMMANDS).length, matrix: Object.keys(axisGateMatrix(ready)).length, blocked_reasons: [axisGate("mdi", queueFull).reason, axisGate("jogStart", estop).reason] }));
|
||||||
Reference in New Issue
Block a user