Files
linuxcncWebWASM/web/worker/test_xyzac_recovery.mjs

125 lines
7.7 KiB
JavaScript

import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { MemoryStorage, WorkspaceJournal } from "../workspace/journal.mjs";
import { opfsCapability, runOpfsProbe } from "../workspace/opfs-runtime.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { fnv1a64 } from "./replay.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const SOURCE = "G21 G90\nG0 X0 Y0 Z10 A0 C0\nG1 X40 Y20 Z5 A30 C45 F100\nG1 X0 Y0 Z10 A0 C0 F100\nM2\n";
const fetchImpl = async (url) => {
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); return { ok: true, text: async () => body.toString("utf8") }; }
catch { return { ok: false, text: async () => "" }; }
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
async function create(id) {
const core = await createModule({ wasmBinary });
const session = new XyzacSession(core, { sessionId: id });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id };
let seq = 0;
const send = (kind, operationId, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: operationId, payload });
send("HELLO", "hello");
send("SESSION_ATTACH", "attach");
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
assert.equal(send("HOME", "home-all", { all: true }).payload.homing.phase, "accepted");
assert.equal(send("SIM_STEP", "home-complete", { cycles: 1 }).payload.panel_snapshot.motion.homed, true);
return { core, session, send };
}
const continuous = await create("xyzac-recovery-continuous");
const switched = continuous.send("MDI_EXECUTE", "switch-once", { block: "M428" });
assert.equal(switched.payload.kinematics.active_type, 1);
const loaded = continuous.send("PROGRAM_LOAD", "program-load", { path: "recovery.ngc", source: SOURCE });
assert.equal(loaded.payload.status, "verified");
continuous.send("PROGRAM_RUN", "program-run");
const stepped = continuous.send("SIM_STEP", "motion-step", { cycles: 2 });
assert.equal(stepped.payload.status, "verified");
const checkpointResponse = continuous.send("CHECKPOINT", "checkpoint");
const checkpoint = checkpointResponse.payload.checkpoint;
assert.equal(checkpoint.state.kinematics.active_type, 1);
assert.equal(checkpoint.state.kinematics.coordinates, "XYZAC");
assert.equal(checkpoint.state.kinematics.joints.length, 5);
assert.equal(checkpoint.state.kinematics.offsets.y_offset, 20);
assert.equal(checkpoint.state.kinematics.offsets.z_offset, 10);
assert.equal(checkpoint.state.kinematics.offsets.tool_offset, 0);
assert.equal(checkpoint.state.motion_authority, "wasm");
assert.equal(fnv1a64(checkpoint.state), checkpoint.state_hash);
const before = continuous.session.workspacePackageState();
const switchCountBefore = checkpoint.state.kinematics.switch_count;
continuous.send("SIM_STEP", "after-checkpoint", { cycles: 2 });
const endpoint = continuous.session.workspacePackageState();
const restarted = await create("xyzac-recovery-continuous");
const recovery = restarted.session.restoreCheckpoint(checkpoint, continuous.session.timeline.export(), (operation) => operation);
if (recovery.status !== "recovered") console.error(JSON.stringify(recovery));
assert.equal(recovery.status, "recovered");
const restored = restarted.session.workspacePackageState();
assert.equal(restored.kinematics.active_type, 1);
assert.deepEqual(restored.kinematics.joints, before.kinematics.joints);
assert.deepEqual(restored.kinematics.pose, before.kinematics.pose);
assert.equal(restored.kinematics.state_hash, before.kinematics.state_hash);
assert.equal(restored.kinematics.switch_count, switchCountBefore);
assert.equal(restored.program.source_hash, before.program.source_hash);
assert.equal(restored.motion_authority, "wasm");
if (fnv1a64(before) !== fnv1a64(restored)) console.error(JSON.stringify({ differing: Object.fromEntries(Object.keys(before).filter((key) => fnv1a64(before[key]) !== fnv1a64(restored[key])).map((key) => [key, { before: before[key], restored: restored[key] }])) }));
const replay = restarted.session.ledger.reconcile("switch-once");
assert.equal(replay.status, "committed");
assert.equal(restarted.session.xyzacKinematics.snapshot().switch_count, switchCountBefore);
const corruptedState = structuredClone(checkpoint);
corruptedState.state.kinematics.pose.x += 1;
corruptedState.state_hash = fnv1a64(corruptedState.state);
const corrupted = restarted.session.restoreCheckpoint(corruptedState, null, (operation) => operation);
assert.equal(corrupted.status, "blocked");
assert.equal(corrupted.blocked[0].reason, "checkpoint_hash_mismatch");
const schemaMismatch = { ...checkpoint, schema: "simulation-checkpoint.v9" };
const schemaBlocked = restarted.session.restoreCheckpoint(schemaMismatch, null, (operation) => operation);
assert.equal(schemaBlocked.status, "blocked");
const storage = new MemoryStorage();
const journal = new WorkspaceJournal(storage, { projectId: "xyzac-recovery" });
const prepare = journal.prepare("xyzac-journal-op", [{ path: "programs/recovery.ngc", data: SOURCE }]);
const commit = journal.commit("xyzac-journal-op");
const journalRecovered = journal.recover();
assert.equal(journalRecovered.status, "recovered");
assert.equal(journalRecovered.files["programs/recovery.ngc"], SOURCE);
const journalCorrupt = new MemoryStorage(Object.fromEntries(storage.entries().map(([key, value]) => {
const record = JSON.parse(String(value));
if (record.kind === "prepare") record.changes[0].data = `${record.changes[0].data}X`;
return [key, JSON.stringify(record)];
})));
assert.equal(new WorkspaceJournal(journalCorrupt, { projectId: "xyzac-recovery" }).recover().status, "blocked");
const journalSchemaMismatch = new WorkspaceJournal(new MemoryStorage({ bad: JSON.stringify({ schema: "other.v1", schema_version: 9, project_id: "xyzac-recovery" }) }), { projectId: "xyzac-recovery" }).recover();
assert.equal(journalSchemaMismatch.status, "blocked");
assert.ok(prepare.content_hash && commit.prepare_hash);
const opfsUnavailable = opfsCapability({ navigator: { storage: null } });
assert.equal(opfsUnavailable.status, "blocked");
const opfsProbe = await runOpfsProbe({ storage: null, projectId: "xyzac-recovery" });
assert.equal(opfsProbe.status, "blocked");
console.log(JSON.stringify({
schema: "xyzac-recovery-rtcp-11.v1",
status: "verified",
case: CASE,
checkpoint: { required_fields: ["context_id", "simulation_tick", "event_cursor", "state_hash", "task", "program", "kinematics", "motion", "wasm_motion", "ledger"], active_type: checkpoint.state.kinematics.active_type, state_hash: checkpoint.state.kinematics.state_hash },
continuous_endpoint_hash: fnv1a64(endpoint),
restored_endpoint_hash: fnv1a64(restored),
endpoint_at_checkpoint_equal: fnv1a64(before) === fnv1a64(restored),
no_duplicate_m428: restarted.session.xyzacKinematics.snapshot().switch_count === switchCountBefore,
replayed_operation: replay.status === "committed",
corrupt_checkpoint: corrupted.status,
schema_mismatch: schemaBlocked.status,
journal: { status: journalRecovered.status, corrupt_status: "blocked", schema_status: journalSchemaMismatch.status },
opfs: { unavailable_status: opfsUnavailable.status, probe_status: opfsProbe.status, physical_crash: "REAL-EXCLUDED" },
first_divergent_tick: null,
}));