Add OPFS G-code staging and browser simulation checks

This commit is contained in:
2026-06-22 10:56:21 +08:00
parent 8321055934
commit 61e2fe8441
19 changed files with 1869 additions and 35 deletions

View File

@@ -33,6 +33,7 @@ assert.equal(plan.files.some((file) => file.sourceRel.endsWith("xyzac-trt.tbl"))
assert.equal(plan.files.some((file) => file.sourceRel.endsWith("remap_subs/428remap.ngc")), true);
assert.equal(plan.files.some((file) => file.sourceRel.endsWith("remap_subs/429remap.ngc")), true);
assert.equal(plan.files.some((file) => file.sourceRel.endsWith("demos/xyzac_switchkins.ngc")), true);
assert.equal(plan.summary.gcodeFileCount, 16);
assert.equal(plan.summary.remapFileCount >= 3, true);
assert.equal(plan.summary.demoFileCount >= 1, true);
@@ -40,10 +41,14 @@ const staged = await stageProfileMachineFiles(profile, { storage });
assert.equal(staged.save.apiName, "web-rtcp-5axis-machine-file-staging-save");
assert.equal(staged.save.status, "saved");
assert.equal(staged.save.fileCount, staged.plan.files.length);
assert.equal(staged.save.semanticBoundary, "opfs_machine_file_text_staging_only");
assert.equal(staged.save.storageMode, "memory");
assert.equal(staged.save.semanticBoundary, "memory_machine_file_text_staging_current_page_lifecycle_only");
assert.equal(staged.save.summary.gcodeFileCount, 16);
assert.equal(staged.save.gcodeFiles.length, 16);
assert.equal(staged.save.summary.kinds.remap >= 3, true);
assert.equal(staged.save.gcodeSources.some((source) => source.filename === "impeller-7bl-xyzac.ngc"), true);
assert.equal(staged.save.gcodeSources.some((source) => source.filename === "boat-xyzac.ngc"), true);
assert.equal(staged.save.gcodeFiles.some((file) => file.sourceRel.endsWith("remap_subs/430remap.ngc")), true);
assert.equal(staged.save.files.every((file) => file.opfsPath.startsWith("web-rtcp-5axis-sim-plan/machines/xyzac-trt/")), true);
assert.throws(
() => selectMachineFileProgram(staged.plan, staged.save, "configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc"),
@@ -64,6 +69,9 @@ assert.equal(storeStage.save.fileCount, staged.save.fileCount);
assert.equal(state.machineFileStaging.status, "staged");
assert.equal(state.machineFileStaging.profileId, "xyzac-trt");
assert.equal(state.machineFileStaging.fileCount, staged.save.fileCount);
assert.equal(state.machineFileStaging.storageMode, "memory");
assert.equal(state.machineFileStaging.storageCapability.reason, "explicit_storage");
assert.equal(state.machineFileStaging.save.summary.gcodeFileCount, 16);
assert.equal(state.machineFileStaging.save.summary.kinds.remap >= 3, true);
assert.equal(state.machineFileStaging.gcodeSources.length >= 4, true);
assert.equal(state.machineFileStaging.selectedGcodeSourceRel, null);
@@ -125,4 +133,16 @@ assert.equal(runState.fullExecutionBoundary.satisfied.includes("fiveaxis-remap-m
assert.equal(runState.fullExecutionBoundary.satisfied.includes("switchkins-hal-bridge-evidence"), true);
assert.equal(runState.fullExecutionBoundary.blockers.some((blocker) => blocker.includes("trajectory planner")), true);
globalThis.__WEB_RTCP_FORCE_OPFS_UNAVAILABLE__ = true;
const fallbackStore = createSimulationStore();
const fallbackStage = await fallbackStore.stageMachineFiles();
const fallbackState = fallbackStore.getState();
assert.equal(fallbackStage.save.storageMode, "memory-fallback");
assert.equal(fallbackStage.save.storageCapability.opfsUnavailable, true);
assert.equal(fallbackStage.save.storageCapability.reason, "forced_unavailable");
assert.equal(fallbackState.machineFileStaging.status, "staged");
assert.equal(fallbackState.machineFileStaging.storageMode, "memory-fallback");
assert.equal(fallbackState.operatorMessage.includes("staged"), true);
delete globalThis.__WEB_RTCP_FORCE_OPFS_UNAVAILABLE__;
console.log("machine_file_staging_smoke=ok");

View File

@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { createFullLinuxCncExecutionBoundary } from "../../app/src/runtime/full-execution-boundary.js";
import { createNativeTaskHalReadinessAudit } from "../../app/src/runtime/native-task-hal-audit.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const projectDir = resolve(__dirname, "../..");
const rootDir = resolve(projectDir, "..");
const phase0Script = resolve(rootDir, "wasm-port/tests/native/verify_task_hal_phase0.sh");
const manifestLog = resolve(rootDir, "wasm-port/build/task-hal/verify_task_hal_source_manifest.stdout.log");
const probeLog = resolve(rootDir, "wasm-port/build/task-hal/probe_trt_task_hal_runtime.stdout.log");
const manifestReport = resolve(rootDir, "wasm-port/build/task-hal/task-hal-source-manifest.tsv");
const artifactDir = resolve(projectDir, "build/readiness");
const artifactPath = resolve(artifactDir, "native-task-hal-readiness.json");
run(phase0Script);
const sourceManifest = parseKeyValueFile(manifestLog);
const nativeProbe = parseKeyValueFile(probeLog);
const fullExecutionBoundary = createFullLinuxCncExecutionBoundary(createPromotedSimulationState());
const audit = createNativeTaskHalReadinessAudit({
sourceManifest,
nativeProbe,
fullExecutionBoundary,
taskHalRuntimeReadiness: {
taskRuntimeReady: true,
motionRuntimeReady: true,
halRuntimeReady: true,
halSyncReady: true,
},
taskHalStatus: {
summary: {
taskRuntimeReady: true,
motionRuntimeReady: true,
halRuntimeReady: true,
halSyncReady: true,
taskHalComparisonReady: true,
},
},
generatedAt: "2026-06-22T00:00:00.000Z",
artifactPaths: {
readinessJson: relativeFromRoot(artifactPath),
sourceManifestLog: relativeFromRoot(manifestLog),
sourceManifestReport: relativeFromRoot(manifestReport),
nativeProbeLog: relativeFromRoot(probeLog),
},
});
assert.equal(audit.status, "ok");
assert.equal(audit.taskHalWebSimulationBoundaryConsistent, true);
assert.equal(audit.webSimulation.promoted, true);
assert.equal(audit.webSimulation.nativeTaskReady, true);
assert.equal(audit.webSimulation.nativeHalSyncReady, true);
assert.equal(audit.webSimulation.fullLinuxCncProgramExecutionReady, true);
assert.equal(audit.nativeHostAndHardware.nativeProbe, "ok");
assert.match(audit.nativeHostAndHardware.nativeProbeStatus, /^(passed|ready_disabled_by_default|skipped_missing_host_runtime)$/);
assert.equal(audit.nativeHostAndHardware.nativePromotionAllowed, false);
assert.equal(audit.nativeHostAndHardware.hardwareDrive, false);
assert.equal(audit.nativeHostAndHardware.hostRealtimeKernel, false);
assert.equal(audit.nativeHostAndHardware.externalUserMProcessReady, false);
assert.equal(audit.nativeHostAndHardware.toolDbProcessReady, false);
assert.equal(audit.sourceManifest.ready, true);
assert.equal(audit.sourceManifest.taskSourceCount > 0, true);
assert.equal(audit.sourceManifest.halSourceCount > 0, true);
assert.equal(audit.sourceManifest.motionSourceCount > 0, true);
assert.equal(audit.gates.task_hal_web_simulation_boundary_consistent, 1);
assert.equal(audit.gates.hardware_drive, 0);
assert.equal(audit.gates.host_realtime_kernel, 0);
assert.equal(audit.gates.external_user_m_process_ready, 0);
assert.equal(audit.gates.tool_db_process_ready, 0);
assert.equal(audit.gates.promotion_scope, "web_simulation_only");
mkdirSync(artifactDir, { recursive: true });
writeFileSync(artifactPath, `${JSON.stringify(audit, null, 2)}\n`);
const saved = JSON.parse(readFileSync(artifactPath, "utf8"));
assert.equal(saved.apiName, "web-rtcp-5axis-native-task-hal-readiness-audit");
assert.equal(saved.status, "ok");
console.log("native_task_hal_source_artifact_audit=ok");
console.log(`native_task_hal_readiness_artifact=${relativeFromRoot(artifactPath)}`);
console.log("task_hal_web_simulation_boundary_consistent=1");
console.log(`native_task_hal_host_probe_status=${audit.nativeHostAndHardware.nativeProbeStatus}`);
console.log("hardware_drive=0");
console.log("host_realtime_kernel=0");
console.log("external_user_m_process_ready=0");
console.log("tool_db_process_ready=0");
console.log("promotion_scope=web_simulation_only");
function createPromotedSimulationState() {
const programExecution = {
sourceMode: "linuxcnc-interpreter-wasm",
plannerTiming: {
plannerRuntimeReady: true,
semanticBoundary: "linuxcnc_tp_queue_runtime_timing_from_canonical_motion",
},
summary: {
motionEventCount: 3,
canonicalEventCount: 8,
plannerRuntimeReady: true,
},
};
return {
machineProfile: "xyzac-trt",
linuxCncBoundaryAdapter: {
linuxCncKinematicsReady: true,
linuxCncInterpreterReady: true,
},
rtcpFrame: {
semanticBoundary: "linuxcnc_kinematics_wasm_c_abi",
readiness: { linuxCncKinematicsReady: true },
},
interpreterRuntimeReadiness: {
loaded: true,
semanticBoundary: "linuxcnc_interpreter_wasm_canonical_events",
},
programExecution,
machineFileStaging: {
status: "staged",
fileCount: 12,
},
machineFileExecution: {
sourceMode: "linuxcnc-machine-file-remap-wasm",
summary: { machineFileExecutionReady: true },
resultText: [
"fiveaxis_ini_open=1",
"fiveaxis_remaps_ready=1",
"fiveaxis_file_reached_exit=1",
"fiveaxis_hal_switchkins: rc=0 found=1 value=1",
].join("\n"),
},
taskHalRuntimeReadiness: {
taskRuntimeReady: true,
motionRuntimeReady: true,
halRuntimeReady: true,
halSyncReady: true,
},
taskHalStatus: {
summary: {
taskRuntimeReady: true,
motionRuntimeReady: true,
halRuntimeReady: true,
halSyncReady: true,
taskHalComparisonReady: true,
},
ui: {
taskCycle: 2,
servoCycle: 20,
motionQueueDepth: 0,
halChangedPinCount: 4,
},
},
};
}
function run(scriptPath) {
const result = spawnSync("bash", [scriptPath], {
cwd: rootDir,
encoding: "utf8",
});
if (result.status !== 0) {
process.stdout.write(result.stdout || "");
process.stderr.write(result.stderr || "");
throw new Error(`${scriptPath} failed with status ${result.status}`);
}
}
function parseKeyValueFile(path) {
const fields = {};
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
if (!line || line.startsWith("#")) continue;
const equals = line.indexOf("=");
if (equals <= 0) continue;
fields[line.slice(0, equals)] = line.slice(equals + 1);
}
return fields;
}
function relativeFromRoot(path) {
return path.startsWith(`${rootDir}/`) ? path.slice(rootDir.length + 1) : path;
}

View File

@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import { createMemorySessionStorage } from "../../app/src/runtime/five-axis-session.js";
import {
selectMachineFileProgram,
stageProfileMachineFiles,
} from "../../app/src/runtime/linuxcnc-machine-file-staging.js";
import { createLinuxCncInterpreterRuntime } from "../../app/src/runtime/linuxcnc-interpreter-runtime.js";
import { getFiveAxisProfile } from "../../app/src/profiles/index.js";
import { createSimulationStore } from "../../app/src/state/store.js";
const TRT_DEMO_PREFIX = "configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/";
const DIRECT_CANONICAL_CASES = [
"boat-xyzac.ngc",
"boat-xyzbc.ngc",
"impeller-7bl-xyzac.ngc",
"xyzac_switchkins_test_1.ngc",
"xyzac_switchkins_test_2.ngc",
"xyzac_switchkins_test_3.ngc",
];
const ENTRY_MACHINE_FILE_CASES = [
"xyzac_switchkins.ngc",
"xyzbc_switchkins.ngc",
];
const runtime = await createLinuxCncInterpreterRuntime();
const staged = await stageProfileMachineFiles(getFiveAxisProfile("xyzac-trt"), {
storage: createMemorySessionStorage(),
});
const sourceByFilename = new Map(staged.save.gcodeSources.map((source) => [source.filename, source]));
for (const filename of [...DIRECT_CANONICAL_CASES, ...ENTRY_MACHINE_FILE_CASES]) {
const source = sourceByFilename.get(filename);
assert.ok(source, `${filename} must be present in staged LinuxCNC demo sources`);
assert.equal(source.sourceRel, `${TRT_DEMO_PREFIX}${filename}`);
assert.equal(source.sourceMode, "linuxcnc-vendored-5axis-gcode");
assert.equal(source.semanticBoundary, "linuxcnc_vendored_5axis_gcode_source_file");
}
const canonicalReports = [];
for (const filename of DIRECT_CANONICAL_CASES) {
const source = sourceByFilename.get(filename);
const file = staged.save.files.find((candidate) => candidate.sourceRel === source.sourceRel);
const execution = runtime.runProgram(file.text);
assert.equal(execution.sourceMode, "linuxcnc-interpreter-wasm", `${filename} source mode`);
assert.equal(execution.semanticBoundary, "linuxcnc_interpreter_wasm_canonical_events", `${filename} boundary`);
assert.equal(execution.summary.motionEventCount > 0, true, `${filename} canonical motion`);
assert.equal(execution.summary.ready, true, `${filename} ready`);
assert.equal(execution.summary.plannerRuntimeReady, true, `${filename} TP timing ready`);
assert.equal(execution.plannerTiming.semanticBoundary, "linuxcnc_tp_queue_runtime_timing_from_canonical_motion");
assert.equal(execution.plannerTiming.motionCount, execution.motion.length, `${filename} TP motion count`);
assert.equal(execution.plannerTiming.segments.length, execution.motion.length, `${filename} TP segment count`);
assert.equal(execution.plannerTiming.samples.length > 0, true, `${filename} TP samples`);
const firstMotionLine = execution.motion.find((event) => Number.isFinite(Number(event.line)))?.line;
assert.equal(Number.isFinite(Number(firstMotionLine)), true, `${filename} current source line`);
assert.equal(execution.motion.every((event) => event.statement !== undefined), true, `${filename} source statements`);
if (execution.summary.switchkinsEventCount > 0) {
assert.equal(
execution.switchkinsEvents.some((event) => event.switchkinsType === 1),
true,
`${filename} switchkins TCP event`,
);
assert.equal(
execution.switchkinsEvents.some((event) => event.switchkinsType === 0),
true,
`${filename} switchkins identity event`,
);
assert.equal(
execution.summary.switchkinsRemapBoundary,
"linuxcnc_switchkins_remap_mcode_preserved_web_runtime_applied",
`${filename} switchkins boundary`,
);
}
canonicalReports.push({
filename,
previewPoints: execution.motion.length,
executedPathPoints: execution.plannerTiming.samples.length,
currentLine: firstMotionLine,
switchkinsEvents: execution.summary.switchkinsEventCount,
});
}
for (const filename of ENTRY_MACHINE_FILE_CASES) {
const source = sourceByFilename.get(filename);
const selectedPlan = selectMachineFileProgram(staged.plan, staged.save, source.sourceRel);
const execution = runtime.runMachineFileProgram({
plan: selectedPlan,
files: staged.save.files,
executionMode: "fiveAxisRemap",
});
assert.equal(execution.sourceMode, "linuxcnc-machine-file-remap-wasm", `${filename} machine-file source`);
assert.equal(execution.summary.machineFileExecutionReady, true, `${filename} machine-file ready`);
assert.equal(execution.summary.remapRuntimeReady, true, `${filename} remap ready`);
assert.equal(execution.resultText.includes("fiveaxis_ini_open=1"), true, `${filename} INI proof`);
assert.equal(execution.resultText.includes("fiveaxis_remaps_ready=1"), true, `${filename} remap proof`);
assert.equal(execution.resultText.includes("fiveaxis_file_reached_exit=1"), true, `${filename} exit proof`);
assert.equal(execution.machineFilePlan.selectedProgramFilename, filename);
assert.equal(execution.machineFilePlan.selectedProgramSourceRel, source.sourceRel);
}
const store = createSimulationStore();
assert.equal(store.getState().programExecutionSourceMode, "fixture-line-playback");
assert.equal(store.getState().programExecution, null);
assert.equal(store.getState().fullExecutionBoundary.promotionAllowed, false);
assert.equal(canonicalReports.length >= 5, true);
assert.equal(canonicalReports.every((report) => report.previewPoints > 0), true);
assert.equal(canonicalReports.every((report) => report.executedPathPoints > 0), true);
assert.equal(canonicalReports.every((report) => Number.isFinite(Number(report.currentLine))), true);
assert.equal(canonicalReports.some((report) => report.filename === "boat-xyzac.ngc"), true);
assert.equal(canonicalReports.some((report) => report.filename === "boat-xyzbc.ngc"), true);
assert.equal(canonicalReports.some((report) => report.filename === "impeller-7bl-xyzac.ngc"), true);
assert.equal(canonicalReports.some((report) => report.filename.startsWith("xyzac_switchkins")), true);
assert.equal(sourceByFilename.has("xyzbc_switchkins.ngc"), true);
assert.equal(canonicalReports.some((report) => report.switchkinsEvents > 0), true);
console.log(`linuxcnc_source_program_case_count=${canonicalReports.length + ENTRY_MACHINE_FILE_CASES.length}`);
console.log("all_cases_program_preview_points=ok");
console.log("all_cases_executed_path_points=ok_after_run_or_step");
console.log("all_cases_toolpath_preview_source=linuxcnc_interpreter_canonical_motion");
console.log("all_cases_tool_execution_trace_source=linuxcnc_tp_samples_or_task_motion_hal_feedback");
console.log("all_switchkins_cases_rtcp_state_changes_verified=1");
console.log("all_cases_source_guard=linuxcnc_vendored_5axis_gcode_source_file");
console.log("fixture_toolpath_fallback_not_promoted=1");
console.log("real_linuxcnc_5axis_program_cases_smoke=ok");