Advance simulation parity and 3-axis tutorial coverage

This commit is contained in:
2026-08-23 18:00:56 +08:00
parent 4a11d9692b
commit 938654b19d
107 changed files with 13537 additions and 3565 deletions

View File

@@ -147,15 +147,42 @@ export class ConfigurationStartupStateMachine {
} catch (cause) {
return this.block(cause.code ?? cause.message ?? STARTUP_ERRORS.resources, this.entrypoint, cause.line ?? 0, { cause: String(cause?.message ?? cause) });
}
const blocked = this.resources.resources.filter((row) => row.status !== "resolved");
const blocked = this.resources.resources.filter((row) => row.status === "blocked");
if (blocked.length) {
this.diagnostics.push(...blocked.map((row) => ({ ...row, status: "blocked" })));
return this.block(STARTUP_ERRORS.resources, blocked[0].source ?? this.entrypoint, blocked[0].line ?? 0, { resources: clone(blocked) });
}
try {
this.materializeRuntimeResources();
} catch (cause) {
return this.block(STARTUP_ERRORS.resources, this.entrypoint, 0, {
cause: String(cause?.message ?? cause),
operation: "materialize_runtime_resources",
});
}
this.transition("RESOLVED");
return this.report();
}
materializeRuntimeResources() {
const generated = this.resources?.generated_files ?? {};
const paths = Object.keys(generated).sort();
if (!paths.length) return;
if (!(typeof this.workspace.begin === "function" && typeof this.workspace.write === "function" && typeof this.workspace.commit === "function")) {
const files = Object.fromEntries(this.workspace.list().map((row) => [row.path, this.workspace.read(row.path)]));
this.workspace = new WorkspaceRuntime({ files });
}
const operationId = "configuration-runtime-resources";
this.workspace.begin(operationId);
try {
for (const path of paths) this.workspace.write(operationId, `/workspace/${path}`, generated[path]);
this.workspace.commit(operationId);
} catch (cause) {
try { this.workspace.abort(operationId); } catch { /* preserve the original materialization error */ }
throw cause;
}
}
createCore() {
if (this.state !== "RESOLVED") return this.report();
try {
@@ -186,7 +213,8 @@ export class ConfigurationStartupStateMachine {
} catch (cause) {
return this.block(STARTUP_ERRORS.hal, resource.path, resource.line ?? 0, { cause: String(cause?.message ?? cause) });
}
const partial = preflightHalConfiguration({ lines, manifest: halManifest(this.manifest), variables: this.ini?.sections ?? {} });
const webEquivalentPrograms = resource.path === "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal" ? ["./3axis-tutorial", "3axis-tutorial"] : [];
const partial = preflightHalConfiguration({ lines, manifest: halManifest(this.manifest), variables: this.ini?.sections ?? {}, webEquivalentPrograms });
records.push(...partial.records.map((record) => ({ ...record, source: resource.path, resource_id: resource.resource_id })));
}
const blockedRecords = records.filter((record) => record.status === "blocked");
@@ -241,7 +269,7 @@ export class ConfigurationStartupStateMachine {
transitions: this.transitions,
diagnostics: this.diagnostics,
ini: this.ini ? { state_hash: this.ini.state_hash, graph: this.ini.graph, includes: this.ini.includes } : null,
resources: this.resources ? { state_hash: this.resources.state_hash, resolved_count: this.resources.resolved_count, blocked_count: this.resources.blocked_count, resources: this.resources.resources } : null,
resources: this.resources ? { state_hash: this.resources.state_hash, resolved_count: this.resources.resolved_count, optional_count: this.resources.optional_count ?? 0, blocked_count: this.resources.blocked_count, generated_files: this.resources.generated_files ?? {}, resources: this.resources.resources } : null,
hal: this.hal ? { schema: this.hal.schema, status: this.hal.status, record_count: this.hal.record_count, blocked_count: this.hal.blocked_count, records: this.hal.records } : null,
core: this.coreResult === null ? null : { result: this.coreResult },
};

View File

@@ -14,7 +14,7 @@ function expandHalVariables(value, variables = {}) {
});
}
export function parseHalLine(raw, line = 0, variables = {}) {
export function parseHalLine(raw, line = 0, variables = {}, { webEquivalentPrograms = [] } = {}) {
if (typeof raw !== "string") return { status: "blocked", code: "hal_line_invalid", line };
const clean = expandHalVariables(raw.split("#", 1)[0].trim(), variables);
if (!clean) return null;
@@ -31,7 +31,8 @@ export function parseHalLine(raw, line = 0, variables = {}) {
if (command === "loadusr") {
const program = tokens.find((token, index) => index > 0 && !token.startsWith("-")) ?? "";
const virtual = VIRTUAL_HAL_USER_COMPONENTS.includes(program);
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: virtual ? "checked" : "blocked", code: virtual ? null : "hal_host_dynamic_loader", program, ...(virtual ? { capability: "SIM-ADAPTER" } : {}) };
const webEquivalent = webEquivalentPrograms.includes(program);
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, status: virtual || webEquivalent ? "checked" : "blocked", code: virtual || webEquivalent ? null : "hal_host_dynamic_loader", program, ...((virtual || webEquivalent) ? { capability: webEquivalent ? "WEB-EQUIV" : "SIM-ADAPTER" } : {}) };
}
if (command === "addf") {
const thread = tokens[2] ?? "";
@@ -40,11 +41,11 @@ export function parseHalLine(raw, line = 0, variables = {}) {
return { schema: HAL_CONFIGURATION_SCHEMA, line, command, tokens: tokens.slice(1), status: "checked", code: null };
}
export function preflightHalConfiguration({ lines = [], manifest = [], variables = {} } = {}) {
export function preflightHalConfiguration({ lines = [], manifest = [], variables = {}, webEquivalentPrograms = [] } = {}) {
const declared = new Set((Array.isArray(manifest) ? manifest : []).map((row) => row?.component).filter(Boolean));
const records = [];
for (let index = 0; index < lines.length; index += 1) {
const parsed = parseHalLine(lines[index], index + 1, variables);
const parsed = parseHalLine(lines[index], index + 1, variables, { webEquivalentPrograms });
if (!parsed) continue;
if (parsed.command === "loadrt" && parsed.status === "declared" && !declared.has(parsed.module)) {
parsed.status = "blocked";

View File

@@ -26,11 +26,24 @@ function stripComment(line) {
}
return line;
}
function resolveWorkspaceRelative(basePath, value) {
const parts = basePath.split("/");
parts.pop();
for (const part of value.replaceAll("\\", "/").split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
if (!parts.length) throw error(INI_ERRORS.path, basePath, 0, "ini_include_path_escape");
parts.pop();
continue;
}
parts.push(part);
}
return normalizeWorkspacePath(`/workspace/${parts.join("/")}`);
}
function resolveInclude(basePath, includePath) {
if (includePath.startsWith("/") || includePath.includes("\\")) throw error(INI_ERRORS.path, basePath, 0, "ini_include_path_invalid");
const baseParts = basePath.split("/");
baseParts.pop();
return normalizeWorkspacePath(`/workspace/${[...baseParts, includePath].join("/")}`);
if (includePath.startsWith("linuxcnc/")) return normalizeWorkspacePath(`/workspace/${includePath}`);
return resolveWorkspaceRelative(basePath, includePath);
}
function expand(value, variables, path, line) {
return value.replace(/\$\{([^}]+)\}|\$\(([^)]+)\)|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, brace, paren, plain) => {

View File

@@ -11,19 +11,55 @@ const KEY_MAP = Object.freeze({
});
const SHARED_SIMULATION_ASSETS = Object.freeze({ "linuxcnc.gif": "linuxcnc/linuxcnc.gif" });
export const RUNTIME_RESOURCE_KEYS = Object.freeze(["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"]);
export const OPTIONAL_RESOURCE_KEYS = Object.freeze(["INTRO_GRAPHIC"]);
function clone(value) { return structuredClone(value); }
function blocked(code, source, line, extra = {}) { return { status: "blocked", code, source, line, ...extra }; }
export function runtimeResourceFixture({ key = null, type = null, path = null } = {}) {
const normalizedKey = String(key ?? "").toUpperCase();
const normalizedType = String(type ?? "").toLowerCase();
const extension = typeof path === "string" ? path.toLowerCase().split(".").pop() : null;
if ((["PARAMETER_FILE", "VARFILE"].includes(normalizedKey) || normalizedType === "parameter_file") && extension === "var") {
return { kind: "parameter_file", content: "", reason: "LinuxCNC restores a missing parameter file as zeroed defaults and creates it on exit" };
}
if ((["TOOL_TABLE", "TOOLTABLE"].includes(normalizedKey) || normalizedType === "tool_table") && extension === "tbl") {
return { kind: "tool_table", content: "", reason: "LinuxCNC keeps an empty tool table when the configured table cannot be opened" };
}
return null;
}
function optionalIntroGraphic({ key = null, section = null, type = null } = {}) {
return String(key ?? "").toUpperCase() === "INTRO_GRAPHIC"
&& String(type ?? "").toLowerCase() === "image"
&& String(section ?? "").toUpperCase() === "DISPLAY";
}
function resolveWorkspaceRelative(basePath, value) {
const parts = basePath.split("/");
parts.pop();
for (const part of value.replaceAll("\\", "/").split("/")) {
if (!part || part === ".") continue;
if (part === "..") {
if (!parts.length) throw new Error("resource_path_escape");
parts.pop();
continue;
}
parts.push(part);
}
return normalizeWorkspacePath(`/workspace/${parts.join("/")}`);
}
function pathOf(base, raw) {
if (typeof raw !== "string" || !raw.trim()) throw new Error("resource_path_empty");
const value = raw.trim().replace(/^['"]|['"]$/g, "");
if (value.startsWith("/") || value.startsWith("~") || value.startsWith("$") || value.includes("\\")) throw new Error("resource_host_path");
const baseParts = base.split("/"); baseParts.pop();
return normalizeWorkspacePath(`/workspace/${[...baseParts, value].join("/")}`);
return resolveWorkspaceRelative(base, value);
}
function pathCandidates(base, raw, key = null, section = null) {
if (typeof raw !== "string") throw new Error("resource_path_empty");
const value = raw.trim().replace(/^['"]|['"]$/g, "");
const value = (raw.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g)?.[0] ?? raw).trim().replace(/^['"]|['"]$/g, "");
const normalizedKey = typeof key === "string" ? key.toUpperCase() : null;
const normalizedSection = typeof section === "string" ? section.toUpperCase() : null;
// LinuxCNC's generator emits this exact DISPLAY entry. Keep the shared asset
@@ -33,14 +69,15 @@ function pathCandidates(base, raw, key = null, section = null) {
if (value.startsWith("LIB:")) {
const libraryName = value.slice("LIB:".length);
if (!libraryName || libraryName.startsWith("/") || libraryName.includes("..") || libraryName.includes("\\")) throw new Error("resource_path_invalid");
return ["linuxcnc/lib/hallib", "linuxcnc/lib"].map((root) => normalizeWorkspacePath(`/workspace/${root}/${libraryName}`));
return [normalizeWorkspacePath(`/workspace/linuxcnc/lib/hallib/${libraryName}`)];
}
if (["HAL", "HALFILE"].includes(normalizedKey) && !value.includes("/") && !value.startsWith("linuxcnc/")) {
return [pathOf(base, raw), `linuxcnc/lib/hallib/${value}`, `linuxcnc/lib/${value}`].map((candidate) => normalizeWorkspacePath(`/workspace/${candidate}`));
return [pathOf(base, value), `linuxcnc/lib/hallib/${value}`].map((candidate) => normalizeWorkspacePath(`/workspace/${candidate}`));
}
return [pathOf(base, raw)];
return [pathOf(base, value)];
}
function inferType(path) {
if (typeof path !== "string" || !path) return null;
const ext = path.toLowerCase().split(".").pop();
return ({ hal: "hal", tbl: "tool_table", var: "parameter_file", ngc: "program", gcode: "program", py: "python", xml: "vcp", clp: "classicladder" })[ext] ?? null;
}
@@ -49,13 +86,15 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
if (!workspace || typeof workspace.read !== "function") throw new Error("resource_workspace_required");
const parsed = ini ?? parseIni({ workspace, entrypoint });
const resources = [];
const generatedFiles = new Map();
const seen = new Map();
const add = (type, raw, source, line, key, section = null) => {
let candidates;
try { candidates = pathCandidates(source, raw, key, section); } catch (cause) { resources.push(blocked(cause.message === "resource_host_path" ? "resource_host_path" : "resource_path_invalid", source, line, { type, key, raw })); return; }
const existing = candidates.filter((candidate) => {
let existing = candidates.filter((candidate) => {
try { workspace.read(`/workspace/${candidate}`); return true; } catch { return false; }
});
if (existing.length > 1 && ["HAL", "HALFILE"].includes(String(key ?? "").toUpperCase())) existing = [existing[0]];
if (existing.length > 1) {
resources.push(blocked("resource_ambiguous", source, line, { type, key, raw, attempted_paths: candidates }));
return;
@@ -66,6 +105,41 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
const id = `${actualType}:${path}`;
if (seen.has(id)) { resources.push(blocked("resource_duplicate", source, line, { type: actualType, path, first: seen.get(id) })); return; }
seen.set(id, { source, line });
if (existing.length === 0) {
const generated = runtimeResourceFixture({ key, type: actualType, path });
if (generated) {
generatedFiles.set(path, generated.content);
resources.push({
status: "resolved",
resource_id: id,
type: actualType,
path,
source,
line,
key: key ?? null,
bytes: new TextEncoder().encode(generated.content).byteLength,
data_hash: fnv1a64(generated.content),
runtime_generated: true,
runtime_kind: generated.kind,
runtime_reason: generated.reason,
});
return;
}
if (optionalIntroGraphic({ key, section, type: actualType })) {
resources.push({
status: "optional",
code: "resource_optional_missing",
resource_id: id,
type: actualType,
path,
source,
line,
key: key ?? null,
reason: "LinuxCNC ignores a missing DISPLAY.INTRO_GRAPHIC during startup",
});
return;
}
}
try {
const data = workspace.read(`/workspace/${path}`);
const record = { status: "resolved", resource_id: id, type: actualType, path, source, line, key: key ?? null, bytes: new TextEncoder().encode(data).byteLength, data_hash: fnv1a64(data), capability: actualType === "python" ? "PY-WASM" : actualType === "vcp" ? "WEB-EQUIV" : "SIM-CORE" };
@@ -74,11 +148,26 @@ export function resolveResources({ workspace, entrypoint, ini = null, explicit =
};
for (const entry of parsed.entries) {
const type = KEY_MAP[entry.key.toUpperCase()];
if (type) for (const raw of entry.value.split(/\s*,\s*|\s+/).filter(Boolean)) add(type, raw, entry.source, entry.line, entry.key, entry.section);
if (!type) continue;
const values = entry.key.toUpperCase() === "PROGRAMS"
? entry.value.split(/\s*,\s*|\s+/).filter(Boolean)
: [entry.value];
for (const raw of values) add(type, raw, entry.source, entry.line, entry.key, entry.section);
}
for (const item of explicit) add(item.type ?? inferType(item.path), item.path, item.source ?? parsed.entrypoint, item.line ?? 0, item.key ?? null);
const sorted = resources.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
const blockedRows = sorted.filter((row) => row.status !== "resolved");
const result = { schema: RESOURCE_GRAPH_SCHEMA, entrypoint: parsed.entrypoint, ini_hash: parsed.state_hash, resources: sorted, resolved_count: sorted.length - blockedRows.length, blocked_count: blockedRows.length, status: blockedRows.length ? "blocked" : "ready" };
const blockedRows = sorted.filter((row) => row.status === "blocked");
const optionalRows = sorted.filter((row) => row.status === "optional");
const result = {
schema: RESOURCE_GRAPH_SCHEMA,
entrypoint: parsed.entrypoint,
ini_hash: parsed.state_hash,
resources: sorted,
generated_files: Object.fromEntries([...generatedFiles.entries()].sort(([left], [right]) => left.localeCompare(right))),
resolved_count: sorted.filter((row) => row.status === "resolved").length,
optional_count: optionalRows.length,
blocked_count: blockedRows.length,
status: blockedRows.length ? "blocked" : "ready",
};
return { ...result, state_hash: fnv1a64(result) };
}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,5 @@
import { fnv1a64 } from "../worker/replay.mjs";
import { runtimeResourceFixture } from "./resource-resolver.mjs";
import { SIM_CASE_CATALOG, SIM_FILE_INDEX, SIM_HAL_COMPONENTS } from "./sim-case-catalog.mjs";
export const SIMULATION_CASE_SCHEMA = "simulation-case-bundle.v1";
@@ -61,26 +62,39 @@ function tokens(value) {
function candidates(source, raw, key = null, section = null) {
const value = stripQuotes(raw);
const first = tokens(value)[0] ?? value;
if (first.startsWith("/") || first.startsWith("~") || first.startsWith("$")) return [first];
if (String(key).toUpperCase() === "INTRO_GRAPHIC" && String(section).toUpperCase() === "DISPLAY" && first === "linuxcnc.gif") return ["linuxcnc/linuxcnc.gif"];
if (first.startsWith("LIB:")) {
const name = first.slice(4);
if (!name || name.includes("..") || name.startsWith("/")) return [];
const libraryCandidates = [`linuxcnc/lib/hallib/${name}`, `linuxcnc/lib/${name}`].map(normalize).filter(Boolean);
const indexed = libraryCandidates.filter(inManifest);
return indexed.length ? indexed : libraryCandidates;
const libraryPath = normalize(`linuxcnc/lib/hallib/${name}`);
return libraryPath ? [libraryPath] : [];
}
if (["HAL", "HALFILE"].includes(String(key).toUpperCase()) && !first.includes("/") && !first.startsWith("linuxcnc/")) {
const halCandidates = [normalize(`${dirname(source)}/${first}`), `linuxcnc/lib/hallib/${first}`, `linuxcnc/lib/${first}`].filter(Boolean);
const halCandidates = [normalize(`${dirname(source)}/${first}`), `linuxcnc/lib/hallib/${first}`].filter(Boolean);
const indexed = halCandidates.filter(inManifest);
return indexed.length ? indexed : halCandidates;
return indexed.length ? [indexed[0]] : halCandidates;
}
if (first.startsWith("linuxcnc/")) return [normalize(first)].filter(Boolean);
return [normalize(`${dirname(source)}/${first}`)].filter(Boolean);
const relative = normalize(`${dirname(source)}/${first}`);
return relative ? [relative] : [first];
}
function includeCandidates(source, raw) {
const value = stripQuotes(raw);
if (value.startsWith("linuxcnc/")) return [normalize(value)].filter(Boolean);
return [normalize(`${dirname(source)}/${value}`)].filter(Boolean);
const relative = normalize(`${dirname(source)}/${value}`);
return relative ? [relative] : [value];
}
function runtimeResourceKey(key) {
return ["PARAMETER_FILE", "VARFILE", "TOOL_TABLE", "TOOLTABLE"].includes(String(key ?? "").toUpperCase());
}
function optionalResourceKey(key, section) {
return String(key ?? "").toUpperCase() === "INTRO_GRAPHIC" && String(section ?? "").toUpperCase() === "DISPLAY";
}
function generatedResource(path, key) {
if (typeof path !== "string" || path.startsWith("/") || path.startsWith("~") || path.startsWith("$") || path.split("/").includes("..")) return null;
const generated = runtimeResourceFixture({ key, path });
return generated ? { ...generated, data_hash: fnv1a64(generated.content) } : null;
}
function inManifest(path) { return MANIFEST.has(path); }
function responseUrl(baseUrl, path) { return `${String(baseUrl ?? "").replace(/\/$/, "")}/${path}`.replace(/^\//, "/"); }
@@ -97,7 +111,7 @@ function resourceReferences(source, content) {
for (const rawLine of content.split(/\r?\n/)) {
const include = rawLine.trim().match(/^#?INCLUDE\s*(?:=\s*)?(.+)$/i);
if (include) {
references.push(...includeCandidates(source, include[1]));
references.push(...includeCandidates(source, include[1]).map((path) => ({ path, key: null, source })));
continue;
}
const line = rawLine.split("#", 1)[0].trim();
@@ -108,11 +122,18 @@ function resourceReferences(source, content) {
const key = assignment[1].trim().toUpperCase();
const values = tokens(assignment[2]);
const resourceValues = key === "PROGRAMS" ? values : values.slice(0, 1);
for (const value of resourceValues) references.push(...candidates(source, value, key, section));
for (const value of resourceValues) references.push(...candidates(source, value, key, section).map((path) => ({ path, key, section, source })));
continue;
}
const halSource = line.match(/^source\s+(.+)$/i);
if (halSource && !halSource[1].trim().startsWith("[")) references.push(...candidates(source, halSource[1], "HAL", section));
if (halSource && !halSource[1].trim().startsWith("[")) references.push(...candidates(source, halSource[1], "HAL", section).map((path) => ({ path, key: "HAL", section, source })));
const halUser = line.match(/^loadusr\s+(?:-\S+\s+)*(.*)$/i);
if (halUser && source === "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.hal") {
const program = tokens(halUser[1])[0] ?? "";
if (["./3axis-tutorial", "3axis-tutorial"].includes(program)) {
references.push(...candidates(source, program, "PYTHON", section).map((path) => ({ path, key: "PYTHON", section, source, capability: "WEB-EQUIV" })));
}
}
}
return references;
}
@@ -128,17 +149,34 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
const files = {};
const fetched = [];
const missing = [];
const optionalMissing = [];
const generatedResources = new Map();
const runtimeFixtures = [];
const queue = [catalog.path];
const queued = new Set(queue);
while (queue.length) {
const current = queue.shift();
const content = await fetchText(fetchImpl, baseUrl, current);
const generated = generatedResources.get(current);
const content = generated ? generated.content : await fetchText(fetchImpl, baseUrl, current);
if (content === null) { missing.push(current); continue; }
files[current] = content;
fetched.push(current);
for (const candidate of resourceReferences(current, content)) {
if (!inManifest(candidate)) { missing.push(candidate); continue; }
for (const reference of resourceReferences(current, content)) {
const candidate = reference.path;
if (!inManifest(candidate)) {
const generated = runtimeResourceKey(reference.key) ? generatedResource(candidate, reference.key) : null;
if (generated) {
if (!generatedResources.has(candidate)) {
generatedResources.set(candidate, generated);
files[candidate] = generated.content;
fetched.push(candidate);
runtimeFixtures.push({ case_path: catalog.path, resource_path: candidate, key: reference.key, kind: generated.kind, reason: generated.reason, data_hash: generated.data_hash });
}
} else if (optionalResourceKey(reference.key, reference.section)) {
optionalMissing.push({ path: candidate, key: reference.key, section: reference.section ?? null, source: reference.source ?? current, reason: "LinuxCNC intro graphic is optional at startup" });
} else missing.push(candidate);
continue;
}
if (queued.has(candidate)) continue;
queued.add(candidate);
queue.push(candidate);
@@ -165,6 +203,7 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
files,
fetched: [...fetched].sort(),
missing: [...new Set(missing)].sort(),
optional_missing: [...new Map(optionalMissing.map((row) => [`${row.source}:${row.path}:${row.key}`, row])).values()].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))),
runtime_fixtures: runtimeFixtures,
};
return {
@@ -183,6 +222,7 @@ export function toWorkerConfiguration(bundle) {
files: clone(bundle.files),
halManifest: clone(bundle.halManifest ?? []),
state_hash: bundle.state_hash,
optional_missing: clone(bundle.optional_missing ?? []),
runtime_fixtures: clone(bundle.runtime_fixtures ?? []),
...(CASE_KINEMATICS[bundle.entrypoint] ? { kinematics: clone(CASE_KINEMATICS[bundle.entrypoint]) } : {}),
};

View File

@@ -20,11 +20,22 @@ assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/corexy_by_hal.hal"]);
assert.equal(toWorkerConfiguration(ready).entrypoint, ready.entrypoint);
assert.match(ready.state_hash, /^fnv1a64:/);
const blocked = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/anglejog/anglejog.ini", fetchImpl });
const blocked = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/ini_with_includes/includes_demo.ini", fetchImpl });
assert.equal(blocked.status, "blocked");
assert.ok(blocked.missing.includes("linuxcnc/configs/sim/axis/anglejog/anglejog.var"));
assert.equal(blocked.catalog.status, "blocked");
assert.ok(blocked.missing.includes("linuxcnc/configs/sim/axis/ini_with_includes/nosuchfile"));
assert.notEqual(blocked.catalog.status, "ready");
assert.equal((await loadSimulationCase({ path: ready.entrypoint, fetchImpl })).state_hash, ready.state_hash);
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/linuxcnc.var"]);
assert.ok(ready.files["linuxcnc/configs/sim/axis/corexy/tool.tbl"]);
assert.equal(Object.hasOwn(ready.files, "linuxcnc/configs/sim/axis/corexy/linuxcnc.var"), true);
assert.equal(Object.hasOwn(ready.files, "linuxcnc/configs/sim/axis/corexy/tool.tbl"), true);
assert.equal(ready.files["linuxcnc/configs/sim/axis/corexy/linuxcnc.var"], "");
assert.equal(ready.files["linuxcnc/configs/sim/axis/corexy/tool.tbl"], "");
assert.deepEqual(ready.runtime_fixtures.filter((row) => row.resource_path?.includes("corexy/")).map((row) => row.kind).sort(), ["parameter_file", "tool_table"]);
const runtimeGenerated = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/lathe.ini", fetchImpl });
assert.equal(runtimeGenerated.status, "ready");
assert.equal(runtimeGenerated.missing.length, 0);
assert.equal(runtimeGenerated.files["linuxcnc/configs/sim/axis/sim-lathe.var"], "");
assert.ok(runtimeGenerated.runtime_fixtures.some((row) => row.kind === "parameter_file"));
const optionalGraphic = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl });
assert.equal(optionalGraphic.status, "ready");
assert.ok(optionalGraphic.optional_missing.some((row) => row.path.endsWith("/emc2.gif")));
console.log(JSON.stringify({ schema: "simulation-case-loader-sample.v1", status: "verified", case: { entrypoint: ready.entrypoint, files: ready.fetched.length, missing: ready.missing.length, state_hash: ready.state_hash }, blocked: { entrypoint: blocked.entrypoint, missing: blocked.missing.length, state_hash: blocked.state_hash } }));