Upload project files

This commit is contained in:
wangdequan
2026-06-28 08:20:33 +08:00
parent f0e96308d2
commit 84a5b54195
162 changed files with 14070 additions and 31381 deletions

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "vitest";
import type { SemanticProgramIr } from "../../src/grl/ir/index.js";
import { IrExecutionRuntime, VirtualControllerStateMachine } from "../../src/controller/index.js";
function program(): SemanticProgramIr {
return {
moduleName: "Main",
symbols: [],
semanticChecks: [],
paths: [],
operations: [],
diagnostics: [],
sourceMap: [
{
kind: "CALL",
id: "call_helper",
procedureId: "main",
sourceMap: { file: "main.grl", line: 2, column: 5 }
},
{
kind: "ALARM",
id: "helper_alarm",
procedureId: "helper",
sourceMap: { file: "main.grl", line: 6, column: 5 }
}
],
procedures: [
{
name: "main",
sourceMap: { file: "main.grl", line: 1, column: 1 },
instructions: [
{
kind: "RAW_STATEMENT",
text: "ready = true",
sourceMap: { file: "main.grl", line: 1, column: 5 }
},
{
kind: "CALL",
target: "helper",
args: [],
sourceMap: { file: "main.grl", line: 2, column: 5 }
},
{
kind: "RETURN",
sourceMap: { file: "main.grl", line: 3, column: 5 }
}
]
},
{
name: "helper",
sourceMap: { file: "main.grl", line: 5, column: 1 },
instructions: [
{
kind: "ALARM",
alarmId: "A1",
message: "helper alarm",
severity: "warning",
sourceMap: { file: "main.grl", line: 6, column: 5 }
},
{
kind: "RETURN",
sourceMap: { file: "main.grl", line: 7, column: 5 }
}
]
}
],
kdlBridge: {
motionRequests: [],
pathRequests: []
}
};
}
describe("virtual controller state machine and IR execution runtime", () => {
it("reports legal and illegal controller command transitions", () => {
const machine = new VirtualControllerStateMachine();
expect(machine.dispatch("run")).toMatchObject({
ok: false,
state: "unloaded",
diagnostic: { code: "VC_INVALID_STATE_TRANSITION" }
});
expect(machine.dispatch("load")).toMatchObject({ ok: true, previous: "unloaded", state: "stopped" });
expect(machine.dispatch("run")).toMatchObject({ ok: true, previous: "stopped", state: "running" });
expect(machine.dispatch("pause")).toMatchObject({ ok: true, previous: "running", state: "paused" });
expect(machine.dispatch("step")).toMatchObject({ ok: true, previous: "paused", state: "paused" });
expect(machine.dispatch("stop")).toMatchObject({ ok: true, previous: "paused", state: "stopped" });
expect(machine.snapshot().diagnostics).toHaveLength(1);
});
it("executes PC, calls, scopes, alarms, trace, source map, and breakpoints", () => {
const runtime = new IrExecutionRuntime();
runtime.load(program());
expect(runtime.snapshot()).toMatchObject({
procedure: "main",
pc: 0,
scopeStack: [{ id: "global" }, { id: "main" }]
});
runtime.setBreakpoint({ id: "helper-alarm", procedure: "helper", source: { line: 6 }, enabled: true });
expect(runtime.step()).toMatchObject({ status: "executed", instruction: { kind: "RAW_STATEMENT" } });
expect(runtime.getVariable("ready")).toBe(true);
expect(runtime.step()).toMatchObject({ status: "executed", instruction: { kind: "CALL" } });
expect(runtime.snapshot()).toMatchObject({
procedure: "helper",
pc: 0,
callStack: [expect.objectContaining({ procedure: "helper", returnTo: { procedure: "main", pc: 2 } })]
});
expect(runtime.step()).toMatchObject({ status: "breakpoint", instruction: { kind: "ALARM" } });
expect(runtime.removeBreakpoint("helper-alarm")).toBe(true);
expect(runtime.step()).toMatchObject({ status: "executed", instruction: { kind: "ALARM" } });
expect(runtime.snapshot().alarmQueue).toEqual([
expect.objectContaining({ id: "A1", message: "helper alarm", severity: "warning" })
]);
expect(runtime.currentSource()).toMatchObject({ sourceMap: { line: 7 } });
expect(runtime.run()).toMatchObject({ status: "completed" });
expect(runtime.snapshot().trace).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "load" }),
expect.objectContaining({ kind: "breakpoint", data: { breakpointId: "helper-alarm" } }),
expect.objectContaining({ kind: "alarm", data: { alarmId: "A1", severity: "warning" } })
])
);
});
});

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import { FLOW_ASSET_MAPPINGS, validateFlowAssetCoverage } from "../../src/docs/index.js";
const FLOW_ROOT = join(process.cwd(), "work/doc/通用机器人项目功能与数据流程图-png");
describe("flow asset coverage", () => {
it("maps every Mermaid and PNG flow asset to tasks and evidence", () => {
const result = validateFlowAssetCoverage(FLOW_ROOT);
expect(result.ok).toBe(true);
expect(result.assets.map((asset) => asset.id)).toEqual(["flow-01", "flow-02", "flow-03", "flow-04", "flow-05"]);
expect(result.assets.every((asset) => asset.mermaidBytes > 0 && asset.pngBytes > 0)).toBe(true);
expect(result.assets.every((asset) => asset.evidence === "EV-211")).toBe(true);
expect(result.assets.every((asset) => asset.tasks.some((task) => task.startsWith("KW-211")))).toBe(true);
expect(result.diagnostics).toEqual([]);
});
it("keeps the expected coverage contract stable", () => {
expect(FLOW_ASSET_MAPPINGS).toEqual([
expect.objectContaining({ id: "flow-01", tasks: expect.arrayContaining(["KW-211.1"]) }),
expect.objectContaining({ id: "flow-02", tasks: expect.arrayContaining(["KW-211.2"]) }),
expect.objectContaining({ id: "flow-03", tasks: expect.arrayContaining(["KW-211.3"]) }),
expect.objectContaining({ id: "flow-04", tasks: expect.arrayContaining(["KW-211.4"]) }),
expect.objectContaining({ id: "flow-05", tasks: expect.arrayContaining(["KW-211.5"]) })
]);
});
});

View File

@@ -0,0 +1,9 @@
language grl 0.1
module A120Smoke
const speed v_joint = joint(40 %)
const zone zf = fine
target home = joint_target { joints: [0 deg, 0 deg, 0 deg, 0 deg, 0 deg, 0 deg] }
proc main()
movej home speed v_joint zone zf
end
end

View File

@@ -0,0 +1,22 @@
language grl 0.1
module A120JointPickPlace
const speed v_fast = joint(40 %)
const speed v_slow = joint(10 + 10 %)
const zone z10 = z(5 + 5 mm)
target home = joint_target { joints: [0 deg, 0 deg, 0 deg, 0 deg, 0 deg, 0 deg] }
target approach = joint_target { joints: [0 deg, -22.918312 deg, 28.647890 deg, 0 deg, 11.459156 deg, 0 deg] }
target pick = joint_target { joints: [11.459156 deg, -20.053523 deg, 25.783101 deg, 5.729578 deg, -11.459156 deg, 17.188734 deg] }
target place = joint_target { joints: [-20.053523 deg, -14.323945 deg, 20.053523 deg, -14.323945 deg, 8.594367 deg, -22.918312 deg] }
path pick_place {
defaults { speed: v_fast, zone: z10 }
point p_home movej home zone fine
point p_approach movej approach
event before p_pick io.do[1] = true
point p_pick movej pick speed v_slow zone fine
point p_place movej place
event after p_place io.do[1] = false
}
proc main()
run_path pick_place
end
end

View File

@@ -0,0 +1,20 @@
language grl 0.1
module A120CartesianBlend
persistent tool gripper = tool { tcp: pose(0 mm, 0 mm, 100 mm, 0 deg, 0 deg, 0 deg), mass: 1 kg }
persistent frame fixture = frame { origin: pose(800 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg) }
const speed v_linear = linear(100 + 50 mm/s)
const zone z10 = z(clamp(10 mm, 1 mm, 50 mm))
target pick = pose_target { pose: pose(500 mm, 0 mm, 0 mm, 0 deg, 0 deg, atan2(0, 1)), tool: gripper, frame: fixture }
target mid = pose_target { pose: pose(550 mm, 50 mm, 0 mm, 0 deg, 0 deg, 0 deg), tool: gripper, frame: fixture }
target arc_end = pose_target { pose: pose(600 mm, 0 mm, 0 mm, 0 deg, 0 deg, 0 deg), tool: gripper, frame: fixture }
path cart_path {
defaults { speed: v_linear, zone: z10, tool: gripper, frame: fixture }
point p_pick movel pick zone fine
point p_arc movec via mid target arc_end speed linear(max(50 mm/s, 150 mm/s)) zone z10
}
proc main()
set_tool gripper
set_frame fixture
run_path cart_path
end
end

View File

@@ -0,0 +1,17 @@
language grl 0.1
module A120IOWaitPulse
const speed v = joint(35 %)
const zone zf = fine
target home = joint_target { joints: [0 deg, 0 deg, 0 deg, 0 deg, 0 deg, 0 deg] }
path io_path {
defaults { speed: v, zone: zf }
point p0 movej home
event at p0 distance 5 + 5 mm pulse io.do[20] duration 50 + 50 ms
}
proc main()
io.do[1] = true
wait io.di[1] == true timeout 1 + 1 s on_timeout alarm "DI1 timeout"
pulse io.do[2] duration 50 + 50 ms
run_path io_path
end
end

View File

@@ -0,0 +1,12 @@
language grl 0.1
module A120ErrorDiagnostics
const speed v = joint(30 %)
const zone zf = fine
target over_limit = joint_target { joints: [200 deg, 0 deg, 0 deg, 0 deg, 0 deg, 0 deg] }
target unreachable_pose = pose_target { pose: pose(3000 mm, 0 mm, 3000 mm, 0 deg, 0 deg, 0 deg) }
proc main()
movej over_limit speed v zone zf
movel unreachable_pose speed linear(100 mm/s) zone zf
wait io.di[99] == true timeout 10 ms on_timeout alarm "expected timeout"
end
end

View File

@@ -0,0 +1,25 @@
language grl 0.1
module A120OperationProcess
const speed v = joint(35 %)
const zone zf = fine
target home = joint_target { joints: [0 deg, 0 deg, 0 deg, 0 deg, 0 deg, 0 deg] }
path op_path {
defaults { speed: v, zone: zf }
point p0 movej home
}
operation pick_op {
kind: handling
path: op_path
process {
dwell_ms: 50 + 50 ms,
clamp_force: max(20, 10)
}
start_action:
io.do[1] = true
end_action:
io.do[1] = false
}
proc main()
run_operation pick_op
end
end

View File

@@ -0,0 +1,11 @@
export {
ABB120_ROBOT_FIXTURE,
ABB_IRB120_3_58_URDF,
ABB_IRB120_APPROACH_JOINTS,
ABB_IRB120_APPROACH_JOINTS as ABB_IRB120_HOME_TO_PICK_JOINTS,
ABB_IRB120_LOAD_OPTIONS,
ABB_IRB120_PICK_JOINTS,
ABB_IRB120_PLACE_JOINTS,
ABB_IRB120_URDF_SOURCE,
ABB_IRB120_ZERO_JOINTS
} from "../../src/fixtures/abb120.js";

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import type { GrlDataDeclaration, GrlPathDeclaration, GrlProcedureDeclaration, GrlTargetDeclaration } from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import { parseGrlExpression } from "../../src/grl/parser/expressionParser.js";
import {
buildMotionContext,
compileGrlDataDeclaration,
compileGrlTargetDeclaration,
compilePathToPlanRequest,
parseIoFlowStatements
} from "../../src/grl/semantic/index.js";
import { evaluateNumberExpression } from "../../src/grl/semantic/constantExpression.js";
import { lexGrl } from "../../src/grl/lexer/index.js";
function expression(source: string) {
return parseGrlExpression(lexGrl(source, { preserveComments: false }).filter((token) => token.kind !== "eof"));
}
describe("GRL expression arithmetic and constant folding", () => {
it("parses arithmetic, comparison, and logical precedence into AST nodes", () => {
expect(expression("1 + 2 * 3")).toMatchObject({
kind: "BinaryExpression",
operator: "+",
right: {
kind: "BinaryExpression",
operator: "*"
}
});
expect(expression("(1 + 2) * 3")).toMatchObject({
kind: "BinaryExpression",
operator: "*",
left: {
kind: "BinaryExpression",
operator: "+"
}
});
expect(expression("not (a == b) or c != d")).toMatchObject({
kind: "BinaryExpression",
operator: "or",
left: {
kind: "UnaryExpression",
operator: "not"
}
});
});
it("evaluates math functions, trigonometry, units, and diagnostics", () => {
expect(evaluateNumberExpression(expression("100 + 50 mm/s"), {
expectedKind: "linear_velocity",
defaultUnit: "mm/s"
})).toBeCloseTo(0.15);
expect(evaluateNumberExpression(expression("(10 + 5) deg"), {
expectedKind: "angle",
defaultUnit: "deg"
})).toBeCloseTo(Math.PI / 12);
expect(evaluateNumberExpression(expression("clamp(20 mm, 1 mm, 10 mm)"), {
expectedKind: "length",
defaultUnit: "mm"
})).toBeCloseTo(0.01);
});
it("reports stable expression errors", () => {
expect(() => evaluateNumberExpression(expression("sqrt(-1)"))).toThrowError(expect.objectContaining({
code: "GRL_EXPR_DOMAIN"
}));
expect(() => evaluateNumberExpression(expression("10 mm + 2 s"))).toThrowError(expect.objectContaining({
code: "GRL_EXPR_UNIT_MISMATCH"
}));
expect(() => evaluateNumberExpression(expression("1 / 0"))).toThrowError(expect.objectContaining({
code: "GRL_EXPR_DIV_ZERO"
}));
});
it("folds speed, zone, target, path event, wait, and pulse expressions", () => {
const program = parseGrl(`language grl 0.1
module MathMotion
const num blend_base = 5 + 5
const speed v_pick = linear(100 + 50 mm/s)
const speed v_safe = linear(max(50 mm/s, 200 mm/s / 2))
const zone z_app = z(clamp(blend_base mm, 1 mm, 50 mm))
target home = joint_target {
joints: [0 deg, (10 + 5) deg, -90 deg]
}
target pick = pose_target {
pose: pose(400 + 50 mm, 20 * 2 mm, sqrt(90000) mm, 0 deg, 0 deg, atan2(1, 1))
}
path main_path {
defaults { speed: v_pick, zone: z_app }
point p0 movej home speed linear(100 + 50 mm/s) zone z(5 + 5 mm)
event at p0 distance 5 + 5 mm pulse io.do[1] duration 50 + 50 ms
}
proc main()
wait io.di[1] == true timeout 1 + 1 s
pulse io.do[2] duration 50 + 50 ms
end
end
`);
const declarations = program.module.declarations;
const [blendBase, vPick, vSafe] = declarations.filter(
(decl): decl is GrlDataDeclaration => decl.kind === "DataDeclaration"
);
expect(compileGrlDataDeclaration(blendBase!)).toMatchObject({ value: 10 });
expect(compileGrlDataDeclaration(vPick!).value).toMatchObject({ kind: "linear" });
expect((compileGrlDataDeclaration(vPick!).value as { velocity: number }).velocity).toBeCloseTo(0.15);
expect(compileGrlDataDeclaration(vSafe!).value).toMatchObject({ kind: "linear" });
expect((compileGrlDataDeclaration(vSafe!).value as { velocity: number }).velocity).toBeCloseTo(0.1);
const [home, pick] = declarations.filter((decl): decl is GrlTargetDeclaration => decl.kind === "TargetDeclaration");
expect(compileGrlTargetDeclaration(home!)).toMatchObject({
target: {
joints: [0, Math.PI / 12, -Math.PI / 2]
}
});
const pickTarget = compileGrlTargetDeclaration(pick!);
expect("pose" in pickTarget.target && pickTarget.target.pose.position).toEqual([0.45, 0.04, 0.3]);
const context = buildMotionContext(
declarations.filter(
(decl): decl is GrlDataDeclaration | GrlTargetDeclaration =>
decl.kind === "DataDeclaration" || decl.kind === "TargetDeclaration"
)
);
const path = declarations.find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
const compiledPath = compilePathToPlanRequest(path, context, {
startJoints: [0, 0, 0],
sampleTime: 0.004
});
expect(compiledPath.request.segments[0]).toMatchObject({
speed: { kind: "linear" },
zone: { kind: "distance", value: 0.01 }
});
expect((compiledPath.request.segments[0]?.speed as { velocity: number }).velocity).toBeCloseTo(0.15);
expect(compiledPath.events[0]).toMatchObject({ distance: 0.01 });
const procedure = declarations.find((decl): decl is GrlProcedureDeclaration => decl.kind === "ProcedureDeclaration")!;
expect(parseIoFlowStatements(procedure.bodyTokens)).toEqual([
expect.objectContaining({ kind: "WAIT", timeout: 2 }),
expect.objectContaining({ kind: "PULSE", duration: 0.1 })
]);
});
});

View File

@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import { postProcessAllBrands } from "../../src/grl/post/index.js";
import {
importBrandProgram,
parseAbbRapid,
parseFanucLs,
parseKukaKrl
} from "../../src/importers/index.js";
import { applyPatch, createOlpProject, validateOlpProject } from "../../src/olp/index.js";
const ABB_MOD = `MODULE PickPlace
PERS tooldata gripper:=[TRUE,[[0,0,100],[1,0,0,0]],[1,[0,0,0],[1,0,0,0],0,0,0]];
CONST robtarget pPick := [[500,0,100],[1,0,0,0],[0,0,0,0],[9E9,9E9,9E9,9E9,9E9,9E9]];
CONST robtarget pMid := [[550,50,100],[1,0,0,0],[0,0,0,0],[9E9,9E9,9E9,9E9,9E9,9E9]];
CONST robtarget pPlace := [[600,0,100],[1,0,0,0],[0,0,0,0],[9E9,9E9,9E9,9E9,9E9,9E9]];
CONST jointtarget jHome := [[0,0,0,0,0,0],[9E9,9E9,9E9,9E9,9E9,9E9]];
PROC main()
MoveJ jHome,v100,fine,gripper;
MoveL pPick,v200,z10,gripper;
MoveC pMid,pPlace,v200,fine,gripper;
ENDPROC
ENDMODULE`;
const KUKA_SRC = `DEF PickPlace()
$TOOL = TOOL_DATA[1]
$BASE = BASE_DATA[2]
PTP HOME
LIN XPICK
CIRC XMID, XPLACE
END`;
const KUKA_DAT = `DEFDAT PickPlace
DECL E6AXIS HOME={A1 0,A2 0,A3 0,A4 0,A5 0,A6 0,E1 0}
DECL E6POS XPICK={X 500,Y 0,Z 100,A 0,B 0,C 0,S 2,T 35,E1 0}
DECL E6POS XMID={X 550,Y 50,Z 100,A 0,B 0,C 0,S 2,T 35,E1 0}
DECL E6POS XPLACE={X 600,Y 0,Z 100,A 0,B 0,C 0,S 2,T 35,E1 0}
ENDDAT`;
const FANUC_LS = `/PROG PICKPLACE
/MN
1:J P[1] 50% FINE ;
2:L P[2] 200mm/sec CNT10 ;
3:C P[3] P[4] 200mm/sec FINE ;
/POS
P[1]{
X = 0.000 mm, Y = 0.000 mm, Z = 0.000 mm,
W = 0.000 deg, P = 0.000 deg, R = 0.000 deg
};
P[2]{
X = 500.000 mm, Y = 0.000 mm, Z = 100.000 mm,
W = 0.000 deg, P = 0.000 deg, R = 0.000 deg
};
P[3]{
X = 550.000 mm, Y = 50.000 mm, Z = 100.000 mm,
W = 0.000 deg, P = 0.000 deg, R = 0.000 deg
};
P[4]{
X = 600.000 mm, Y = 0.000 mm, Z = 100.000 mm,
W = 0.000 deg, P = 0.000 deg, R = 0.000 deg
};
/END`;
describe("brand import MVP", () => {
it("parses ABB RAPID targets, motions, tool hints, and produces runnable GRL", () => {
const parsed = parseAbbRapid([{ name: "PickPlace.mod", text: ABB_MOD }]);
const result = importBrandProgram("abb", [{ name: "PickPlace.mod", text: ABB_MOD }], {
projectName: "AbbImport",
generatedAt: "2026-06-27T00:00:00.000Z"
});
const patched = applyPatch(createOlpProject({ id: "abb_import", name: "AbbImport" }), result.patch);
expect(parsed.targets.map((target) => target.name)).toEqual(["pPick", "pMid", "pPlace", "jHome"]);
expect(parsed.points.map((point) => [point.motion, point.targetId, point.viaTargetId])).toEqual([
["movej", "target_jHome", undefined],
["movel", "target_pPick", undefined],
["movec", "target_pPlace", "target_pMid"]
]);
expect(validateOlpProject(patched).ok).toBe(true);
expect(result.grl).toContain("operation abb_operation");
expect(result.ir.paths[0]?.motions.map((motion) => motion.kind)).toEqual(["MOVEJ", "MOVEL", "MOVEC"]);
expect(result.report.status).toBe("warn");
expect(result.report.sections.find((section) => section.name === "import")?.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
"IMPORT_TARGETS",
"IMPORT_MOTIONS",
"IMPORT_TOOL_DETECTED"
]);
expect(result.importReport).toEqual([
expect.objectContaining({ code: "IMPORT_TOOL_DETECTED", severity: "info" })
]);
expect(postProcessAllBrands(result.ir).outputs.abb.text).toContain("MoveC pMid,pPlace");
});
it("parses KUKA KRL src/dat movement and frame/tool hints", () => {
const parsed = parseKukaKrl([
{ name: "PickPlace.src", text: KUKA_SRC },
{ name: "PickPlace.dat", text: KUKA_DAT }
]);
const result = importBrandProgram("kuka", [
{ name: "PickPlace.src", text: KUKA_SRC },
{ name: "PickPlace.dat", text: KUKA_DAT }
], {
projectName: "KukaImport",
generatedAt: "2026-06-27T00:00:00.000Z"
});
expect(parsed.targets.map((target) => target.name).sort()).toEqual(["HOME", "XMID", "XPICK", "XPLACE"]);
expect(result.parsed.path.points.map((point) => point.motion)).toEqual(["movej", "movel", "movec"]);
expect(result.report.sections.find((section) => section.name === "import")?.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
"IMPORT_TARGETS",
"IMPORT_MOTIONS",
"IMPORT_TOOL_DETECTED",
"IMPORT_FRAME_DETECTED"
]);
expect(result.report.status).toBe("warn");
expect(postProcessAllBrands(result.ir).outputs.kuka.text).toContain("CIRC XMID, XPLACE");
});
it("parses FANUC LS P/PR style positions and circular moves", () => {
const parsed = parseFanucLs([{ name: "PICKPLACE.ls", text: FANUC_LS }]);
const result = importBrandProgram("fanuc", [{ name: "PICKPLACE.ls", text: FANUC_LS }], {
projectName: "FanucImport",
generatedAt: "2026-06-27T00:00:00.000Z"
});
expect(parsed.targets.map((target) => target.name)).toEqual(["P1", "P2", "P3", "P4"]);
expect(result.patch).toEqual(expect.arrayContaining([
expect.objectContaining({ op: "add", path: "/targets/-" }),
expect.objectContaining({ op: "add", path: "/paths/-" }),
expect.objectContaining({ op: "add", path: "/operations/-" })
]));
expect(result.grl).toContain("module FanucImport");
expect(result.parsed.path.points.map((point) => [point.motion, point.targetId, point.viaTargetId])).toEqual([
["movej", "target_P1", undefined],
["movel", "target_P2", undefined],
["movec", "target_P4", "target_P3"]
]);
expect(result.ir.operations[0]).toMatchObject({
operationId: "fanuc_operation",
kind: "imported_program",
pathId: "fanuc_path"
});
expect(postProcessAllBrands(result.ir).outputs.fanuc.text).toContain("C P3 P4");
});
});

View File

@@ -0,0 +1,415 @@
import { describe, expect, it } from "vitest";
import type { GrlDataDeclaration, GrlPathDeclaration, GrlTargetDeclaration } from "../../src/grl/ast/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import { postProcessAllBrands } from "../../src/grl/post/index.js";
import {
buildMotionContext,
compilePathToPlanRequest,
parseProcedureRunPathStatements
} from "../../src/grl/semantic/index.js";
import { importBrandProgram } from "../../src/importers/index.js";
import type { KdlRuntimeHandlers } from "../../src/kdl/rpc.js";
import { createKdlWorkerRuntime } from "../../src/kdl/runtime.js";
import type {
FkResult,
IkResult,
JacobianResult,
KdlApiMethod,
LimitCheckResult,
MoveJRequest,
PathPlanRequest,
PathPlanResult,
RobotInfo,
SingularityResult,
TrajectoryResult
} from "../../src/kdl/types.js";
import { dispatchKdlRpcRequest } from "../../src/kdl/workerRpc.js";
import { KdlRuntimeBridge, MotionQueue, type MotionPlanner } from "../../src/runtime/index.js";
import {
ABB_IRB120_3_58_URDF,
ABB_IRB120_HOME_TO_PICK_JOINTS,
ABB_IRB120_LOAD_OPTIONS,
ABB_IRB120_PICK_JOINTS,
ABB_IRB120_PLACE_JOINTS,
ABB_IRB120_URDF_SOURCE,
ABB_IRB120_ZERO_JOINTS
} from "../fixtures/abbIrb120.js";
const ABB120_GRL_PROGRAM = `language grl 0.1
module Abb120Cell
const speed v_fast = joint(40 %)
const speed v_slow = joint(20 %)
const speed v_linear = linear(150 mm/s)
const zone z10 = z(10 mm)
target home = joint_target {
joints: [0 deg, 0 deg, 0 deg, 0 deg, 0 deg, 0 deg]
}
target pick = joint_target {
joints: [11.459156 deg, -20.053523 deg, 25.783101 deg, 5.729578 deg, -11.459156 deg, 17.188734 deg]
}
target place = joint_target {
joints: [-20.053523 deg, -14.323945 deg, 20.053523 deg, -14.323945 deg, 8.594367 deg, -22.918312 deg]
}
target unreachable_pose = pose_target {
pose: pose(300 mm, 100 mm, 500 mm, 0 deg, 90 deg, 0 deg)
}
path joint_pick_place {
defaults {
speed: v_fast,
zone: z10
}
point p_home movej home zone fine
point p_pick movej pick speed v_slow
point p_place movej place
event before p_pick io.do[1] = true
event after p_place io.do[1] = false
}
proc main()
run_path joint_pick_place
movej unreachable_pose speed v_linear zone fine
end
end
`;
const ABB120_RAPID = `MODULE ABB120_PICK_PLACE
CONST jointtarget jHome := [[0,0,0,0,0,0],[9E9,9E9,9E9,9E9,9E9,9E9]];
CONST jointtarget jPick := [[11.459156,-20.053523,25.783101,5.729578,-11.459156,17.188734],[9E9,9E9,9E9,9E9,9E9,9E9]];
CONST jointtarget jPlace := [[-20.053523,-14.323945,20.053523,-14.323945,8.594367,-22.918312],[9E9,9E9,9E9,9E9,9E9,9E9]];
CONST robtarget pScan := [[300,100,500],[1,0,0,0],[0,0,0,0],[9E9,9E9,9E9,9E9,9E9,9E9]];
PROC main()
MoveJ jHome,v100,fine,tool0;
MoveJ jPick,v50,z10,tool0;
MoveJ jPlace,v50,fine,tool0;
MoveL pScan,v100,fine,tool0;
ENDPROC
ENDMODULE`;
async function createAbb120Robot() {
const runtime = createKdlWorkerRuntime();
await dispatchKdlRpcRequest(runtime, { id: 1, method: "init", payload: [{}] });
const response = await dispatchKdlRpcRequest(runtime, {
id: 2,
method: "loadRobotFromUrdf",
payload: [ABB_IRB120_3_58_URDF, ABB_IRB120_LOAD_OPTIONS]
});
expect(response.ok).toBe(true);
return { runtime, handle: response.result as number };
}
async function rpc<T>(
runtime: KdlRuntimeHandlers,
id: number,
method: KdlApiMethod,
payload: unknown[]
): Promise<T> {
const response = await dispatchKdlRpcRequest(runtime, { id, method, payload });
expect(response.ok).toBe(true);
return response.result as T;
}
function abb120PathRequest(): PathPlanRequest {
const program = parseGrl(ABB120_GRL_PROGRAM);
const declarations = program.module.declarations;
const path = declarations.find((decl): decl is GrlPathDeclaration => decl.kind === "PathDeclaration")!;
const context = buildMotionContext(
declarations.filter(
(decl): decl is GrlDataDeclaration | GrlTargetDeclaration =>
decl.kind === "DataDeclaration" || decl.kind === "TargetDeclaration"
)
);
return compilePathToPlanRequest(path, context, {
startJoints: [...ABB_IRB120_ZERO_JOINTS],
sampleTime: 0.02
}).request;
}
function moveJRequest(targetJoints: readonly number[]): MoveJRequest {
return {
startJoints: [...ABB_IRB120_ZERO_JOINTS],
target: {
id: "abb120_pick",
joints: [...targetJoints]
},
speed: {
kind: "joint_percent",
value: 0.35
},
zone: {
kind: "fine"
},
sampleTime: 0.02
};
}
function expectCloseArray(actual: number[] | undefined, expected: readonly number[], digits = 6): void {
expect(actual).toHaveLength(expected.length);
for (const [index, expectedValue] of expected.entries()) {
expect(actual?.[index]).toBeCloseTo(expectedValue, digits);
}
}
describe("ABB IRB120 programs with ROS-Industrial URDF", () => {
it("loads the ABB120 URDF fixture with source provenance and expected 6R chain", async () => {
const { runtime, handle } = await createAbb120Robot();
const info = await rpc<RobotInfo>(runtime, 3, "getRobotInfo", [handle]);
expect(ABB_IRB120_URDF_SOURCE).toMatchObject({
repository: "https://github.com/ros-industrial/abb",
entrypoint: "abb_irb120_support/urdf/irb120_3_58.xacro"
});
expect(info).toMatchObject({
robotId: "abb_irb120_3_58",
name: "abb_irb120_3_58",
baseLink: "base_link",
tipLink: "tool0",
dof: 6,
jointNames: ["joint_1", "joint_2", "joint_3", "joint_4", "joint_5", "joint_6"]
});
expect(info.limits.map((limit) => [limit.name, limit.lower, limit.upper])).toEqual([
["joint_1", -2.87979, 2.87979],
["joint_2", -1.91986, 1.91986],
["joint_3", -1.91986, 1.22173],
["joint_4", -2.79253, 2.79253],
["joint_5", -2.094395, 2.094395],
["joint_6", -6.98132, 6.98132]
]);
});
it("runs FK, link poses, Jacobian, limit checks, and singularity diagnostics", async () => {
const { runtime, handle } = await createAbb120Robot();
const zeroFk = await rpc<FkResult>(runtime, 4, "fk", [handle, [...ABB_IRB120_ZERO_JOINTS]]);
const pickFk = await rpc<FkResult>(runtime, 5, "fk", [handle, [...ABB_IRB120_PICK_JOINTS]]);
const links = await rpc<{ linkPoses: Array<{ link: string }> }>(runtime, 6, "fkAllLinks", [
handle,
[...ABB_IRB120_ZERO_JOINTS]
]);
const jacobian = await rpc<JacobianResult>(runtime, 7, "jacobian", [handle, [...ABB_IRB120_PICK_JOINTS]]);
const limits = await rpc<LimitCheckResult>(runtime, 8, "checkJointLimits", [
handle,
[3.2, 0, 0, 0, 0, 0]
]);
const singularity = await rpc<SingularityResult>(runtime, 9, "checkSingularity", [
handle,
[...ABB_IRB120_ZERO_JOINTS]
]);
expect(zeroFk.tcp.position[0]).toBeCloseTo(0.374);
expect(zeroFk.tcp.position[1]).toBeCloseTo(0);
expect(zeroFk.tcp.position[2]).toBeCloseTo(0.63);
expect(zeroFk.tcp.quaternion[1]).toBeCloseTo(Math.SQRT1_2);
expect(zeroFk.tcp.quaternion[3]).toBeCloseTo(Math.SQRT1_2);
expect(pickFk.tcp.position).not.toEqual(zeroFk.tcp.position);
expect(links.linkPoses.map((entry) => entry.link)).toEqual([
"base_link",
"link_1",
"link_2",
"link_3",
"link_4",
"link_5",
"link_6",
"flange",
"tool0"
]);
expect(jacobian).toMatchObject({ ok: true, rows: 6, cols: 6 });
expect(Array.from(jacobian.data)).toHaveLength(36);
expect(limits.ok).toBe(false);
expect(limits.diagnostics[0]).toMatchObject({
code: "KDL_JOINT_LIMIT",
severity: "error"
});
expect(singularity.ok).toBe(true);
expect(typeof singularity.nearSingularity).toBe("boolean");
expect(Number.isFinite(singularity.manipulability)).toBe(true);
expect(Number.isFinite(singularity.conditionNumber)).toBe(true);
});
it("plans ABB120 MoveJ and multi-segment joint path programs", async () => {
const { runtime, handle } = await createAbb120Robot();
const moveJ = await rpc<TrajectoryResult>(runtime, 10, "planMoveJ", [
handle,
moveJRequest(ABB_IRB120_PICK_JOINTS)
]);
const path = await rpc<PathPlanResult>(runtime, 11, "planPath", [handle, abb120PathRequest()]);
const validation = await rpc<{ ok: boolean; reachable: boolean; cycleTime?: number }>(runtime, 12, "validatePath", [
handle,
abb120PathRequest()
]);
expect(moveJ).toMatchObject({
ok: true,
motion: "MOVEJ",
meta: {
targetType: "joint",
qStart: [...ABB_IRB120_ZERO_JOINTS],
qEnd: [...ABB_IRB120_PICK_JOINTS]
}
});
expect(moveJ.points.length).toBeGreaterThan(2);
expectCloseArray(moveJ.points.at(-1)?.joints, ABB_IRB120_PICK_JOINTS);
expect(path.ok).toBe(true);
expect(path.segments.map((segment) => segment.motion)).toEqual(["MOVEJ", "MOVEJ", "MOVEJ"]);
expectCloseArray(path.points.at(-1)?.joints, ABB_IRB120_PLACE_JOINTS);
expect(validation).toMatchObject({
ok: true,
reachable: true
});
expect(validation.cycleTime).toBeGreaterThan(0);
});
it("compiles GRL path/procedure, posts all brands, and replays through MotionQueue", async () => {
const { runtime, handle } = await createAbb120Robot();
const program = parseGrl(ABB120_GRL_PROGRAM);
const pathRequest = abb120PathRequest();
const ir = await import("../../src/grl/semantic/index.js").then(({ compileSemanticProgram }) =>
compileSemanticProgram(program, {
startJoints: [...ABB_IRB120_ZERO_JOINTS],
sampleTime: 0.02
})
);
const procedure = program.module.declarations.find((decl) => decl.kind === "ProcedureDeclaration");
expect(procedure && parseProcedureRunPathStatements(procedure).map((instruction) => instruction.pathId)).toEqual([
"joint_pick_place"
]);
expect(ir.kdlBridge.pathRequests[0]).toMatchObject({
pathId: "joint_pick_place",
segments: [
{ id: "p_home", motion: "MOVEJ" },
{ id: "p_pick", motion: "MOVEJ" },
{ id: "p_place", motion: "MOVEJ" }
]
});
expect(ir.procedures[0]?.instructions.map((instruction) => instruction.kind)).toEqual([
"RUN_PATH",
"MOVEJ"
]);
const posted = postProcessAllBrands(ir);
expect(posted.outputs.abb.text).toContain("MoveJ unreachable_pose,v150,fine,tool0;");
expect(posted.outputs.fanuc.text).toContain("J unreachable_pose 150mm/sec FINE");
expect(posted.outputs.kuka.text).toContain("PTP unreachable_pose Vel=0.150m/s");
const planner: MotionPlanner = {
planPath: (_robotHandle, request) =>
dispatchKdlRpcRequest(runtime, {
id: 20,
method: "planPath",
payload: [handle, request]
}).then((response) => response.result as PathPlanResult)
};
const queue = new MotionQueue({
startJoints: [...ABB_IRB120_ZERO_JOINTS],
sampleTime: 0.02,
robotHandle: handle,
planner
});
const bridge = new KdlRuntimeBridge(ir, queue);
const pathId = pathRequest.pathId;
expect(pathId).toBe("joint_pick_place");
const items = bridge.enqueueInstruction({ kind: "RUN_PATH", pathId: pathId! });
await queue.planAll();
const plannedDuration = queue.snapshot().items[0]?.planned?.duration ?? 0;
const halfway = queue.advance(plannedDuration / 2);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: "path",
source: {
pathId: "joint_pick_place"
}
});
expect(queue.snapshot().items[0]?.planned).toMatchObject({
ok: true
});
expect(halfway?.source).toMatchObject({
pathId: "joint_pick_place"
});
expect(halfway?.point.joints).toHaveLength(6);
});
it("solves 6R ABB120 pose IK and reports straight-line MOVEL sampling limits", async () => {
const { runtime, handle } = await createAbb120Robot();
const pickPose = await rpc<FkResult>(runtime, 30, "fk", [handle, [...ABB_IRB120_HOME_TO_PICK_JOINTS]]);
const ik = await rpc<IkResult>(runtime, 31, "ik", [
handle,
[...ABB_IRB120_ZERO_JOINTS],
pickPose.tcp,
{ positionTolerance: 1e-6 }
]);
const moveL = await rpc<TrajectoryResult>(runtime, 32, "planMoveL", [
handle,
{
startJoints: [...ABB_IRB120_ZERO_JOINTS],
target: {
id: "fk_pose_target",
pose: pickPose.tcp
},
speed: {
kind: "linear",
velocity: 0.1
},
zone: {
kind: "fine"
},
sampleTime: 0.02
}
]);
expect(ik).toMatchObject({
ok: true,
diagnostics: []
});
expect(ik.joints).toHaveLength(6);
expect(ik.residualPosition).toBeLessThanOrEqual(1e-6);
expect(moveL).toMatchObject({
ok: false,
motion: "MOVEL",
meta: {
targetId: "fk_pose_target"
}
});
expect(moveL.diagnostics).toContainEqual(
expect.objectContaining({
severity: "error"
})
);
});
it("imports an ABB RAPID program with ABB120-style joint targets and reports unreachable pose moves", async () => {
const { runtime, handle } = await createAbb120Robot();
const imported = importBrandProgram("abb", [{ name: "abb120.mod", text: ABB120_RAPID }], {
projectName: "Abb120Imported",
generatedAt: "2026-06-27T00:00:00.000Z"
});
const pathRequest = imported.ir.kdlBridge.pathRequests[0]!;
const planned = await rpc<PathPlanResult>(runtime, 40, "planPath", [
handle,
{
...pathRequest,
startJoints: [...ABB_IRB120_ZERO_JOINTS],
sampleTime: 0.02,
stopOnError: false
}
]);
expect(imported.report.status).toBe("warn");
expect(imported.ir.paths[0]?.motions.map((motion) => motion.kind)).toEqual([
"MOVEJ",
"MOVEJ",
"MOVEJ",
"MOVEL"
]);
expect(postProcessAllBrands(imported.ir).outputs.abb.text).toContain("MoveL pScan");
expect(planned.segments.map((segment) => [segment.motion, segment.ok])).toEqual([
["MOVEJ", true],
["MOVEJ", true],
["MOVEJ", true],
["MOVEL", false]
]);
expect(planned.diagnostics).toContainEqual(
expect.objectContaining({
code: "KDL_TARGET_UNREACHABLE",
segmentId: "import_p03"
})
);
});
});

View File

@@ -0,0 +1,82 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { runAbb120Suite } from "../../src/suites/abb120Suite.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import { compileSemanticProgram } from "../../src/grl/semantic/index.js";
import { ABB_IRB120_ZERO_JOINTS } from "../fixtures/abbIrb120.js";
const WEB_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
const PROGRAM_DIR = join(WEB_ROOT, "tests", "fixtures", "abb120", "programs");
const APP_DIR = join(WEB_ROOT, "app");
const PROGRAM_NAMES = [
"A120_00_Smoke.grl",
"A120_10_JointPickPlace.grl",
"A120_20_CartesianBlend.grl",
"A120_30_IOWaitPulse.grl",
"A120_40_ErrorDiagnostics.grl",
"A120_50_OperationProcess.grl"
];
function loadPrograms() {
return PROGRAM_NAMES.map((name) => ({
name,
text: readFileSync(join(PROGRAM_DIR, name), "utf8")
}));
}
describe("ABB120 suite artifacts", () => {
it("ships six parseable GRL programs with source maps and KDL bridge requests", () => {
for (const program of loadPrograms()) {
const ast = parseGrl(program.text);
const ir = compileSemanticProgram(ast, {
startJoints: [...ABB_IRB120_ZERO_JOINTS],
sampleTime: 0.02
});
expect(ast.kind).toBe("Program");
expect(ir.moduleName).toMatch(/^A120/);
expect(ir.sourceMap.length + ir.kdlBridge.motionRequests.length + ir.kdlBridge.pathRequests.length).toBeGreaterThan(0);
}
});
it("creates job, post, roundtrip, report, delivery, and screenshot evidence paths", () => {
const job = runAbb120Suite(loadPrograms(), { now: "2026-06-27T12:00:00.000Z" });
expect(job.job_id).toBe("A120-JOB-20260627120000-doc");
expect(job.robot.robotId).toBe("abb_irb120_3_58");
expect(job.programs.map((program) => program.name)).toEqual(PROGRAM_NAMES);
expect(job.post.filenames).toEqual(expect.arrayContaining([
"A120Smoke.mod",
"A120Smoke.ls",
"A120Smoke.src"
]));
expect(job.roundtrip.status).toMatch(/pass|warn/);
expect(job.report_id).toBe("A120-REPORT-20260627120000");
expect(job.artifacts).toMatchObject({
jobJson: "A120-JOB-20260627120000-doc/job.json",
reportHtml: "A120-JOB-20260627120000-doc/reports/A120-REPORT-20260627120000.html",
deliveryPackageJson: "A120-JOB-20260627120000-doc/delivery/package.json",
desktopScreenshot: "A120-JOB-20260627120000-doc/screenshots/virtual-controller-desktop.png",
mobileScreenshot: "A120-JOB-20260627120000-doc/screenshots/virtual-controller-mobile.png"
});
});
it("has an HTML virtual controller entry with required panels", () => {
const html = readFileSync(join(APP_DIR, "virtual-controller.html"), "utf8");
const css = readFileSync(join(APP_DIR, "virtual-controller.css"), "utf8");
const js = readFileSync(join(APP_DIR, "virtual-controller.js"), "utf8");
expect(html).toContain("ABB120 Station");
expect(html).toContain("Path: pick_place");
expect(html).toContain("Controller");
expect(html).toContain("Motion Queue");
expect(html).toContain("Reports");
expect(html).toContain('src="./virtual-controller.js"');
expect(html).not.toContain('src="./virtual-controller.ts"');
expect(css).toContain(".viewport");
expect(css).toContain("@media");
expect(js).toContain("data-command");
expect(js).toContain("data-tab");
});
});

View File

@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
applyCalibrationRecords,
commercialExtensionBoundaries,
compareControllerLowSpeedRun,
createResourceLibraryTemplate,
detectBasicCollisions,
generatePathFromGeometry,
instantiateProcessTemplate,
locateCollisionTime,
sampleOlpProject,
validateOlpProject
} from "../../src/olp/index.js";
describe("OLP geometry, calibration, and multi-robot extensions", () => {
it("generates path targets from points, edges, and curves", () => {
const pointPath = generatePathFromGeometry(
{ kind: "points", points: [[500, 0, 0], [600, 0, 0]] },
{ pathId: "point_path", speedId: "v_linear", zoneId: "z10" }
);
const edgePath = generatePathFromGeometry(
{ kind: "edge", start: [0, 0, 0], end: [100, 0, 0], samples: 3 },
{ pathId: "edge_path" }
);
const curvePath = generatePathFromGeometry(
{ kind: "curve", controlPoints: [[0, 0, 0], [50, 50, 0], [100, 0, 0]], samples: 3 },
{ pathId: "curve_path", targetPrefix: "curve" }
);
expect(pointPath.targets.map((target) => target.pose)).toEqual([
[500, 0, 0, 0, 0, 0],
[600, 0, 0, 0, 0, 0]
]);
expect(edgePath.targets.map((target) => target.pose?.[0])).toEqual([0, 50, 100]);
expect(curvePath.targets.map((target) => target.name)).toEqual(["curve_00", "curve_01", "curve_02"]);
expect(curvePath.path.points.map((point) => point.motion)).toEqual(["movej", "movel", "movel"]);
});
it("detects primitive collisions and locates the first time point", () => {
const project = sampleOlpProject();
const report = detectBasicCollisions([
{ time: 0, pointId: "p0", position: [100, 0, 0] },
{ time: 1.25, pointId: "p1", position: [550, 0, -40] }
], project.resources.collisionObjects);
expect(report.ok).toBe(false);
expect(report.hits).toEqual([
expect.objectContaining({
objectId: "fixture_box",
pointId: "p1",
time: 1.25
})
]);
expect(locateCollisionTime(report)).toBe(1.25);
});
it("saves and applies TCP/frame/base/external-axis calibration records", () => {
const project = sampleOlpProject();
const calibrated = applyCalibrationRecords(project, [
{
id: "tcp_cal_2",
name: "tcp_cal_2",
kind: "tcp",
targetResourceId: "tool_gripper",
poseDelta: [1, 2, 3, 0, 0, 0]
},
{
id: "fixture_cal_1",
name: "fixture_cal_1",
kind: "frame",
targetResourceId: "frame_fixture",
poseDelta: [10, 0, 0, 0, 0, 0]
},
{
id: "track_cal_1",
name: "track_cal_1",
kind: "external_axis",
targetResourceId: "track_1",
axisOffset: 2.5
}
]);
expect(calibrated.resources.tools.find((tool) => tool.id === "tool_gripper")?.tcp).toEqual([1, 2, 103, 0, 0, 0]);
expect(calibrated.resources.frames.find((frame) => frame.id === "frame_fixture")?.pose).toEqual([810, 0, 0, 0, 0, 0]);
expect(calibrated.externalAxes[0]?.metadata).toEqual({ calibrationOffset: 2.5 });
expect(validateOlpProject(calibrated).ok).toBe(true);
});
it("creates resource library and process template delivery boundaries", () => {
const project = sampleOlpProject();
const library = createResourceLibraryTemplate(project);
const template = instantiateProcessTemplate(project.processTemplates[0]!, "pick_op");
const boundaries = commercialExtensionBoundaries();
expect(library).toMatchObject({
robots: [{ id: "robot_1", brand: "abb", model: "IRB120" }],
collisionObjects: [{ id: "fixture_box" }]
});
expect(template).toEqual({
operationId: "pick_op",
operationKind: "handling",
defaults: { speedId: "v_linear", zoneId: "z10" },
deliveryTags: ["source", "post", "report"]
});
expect(boundaries).toEqual(expect.arrayContaining([
expect.objectContaining({ feature: "cad_kernel", mvp: false }),
expect.objectContaining({ feature: "delivery_package", mvp: true })
]));
});
it("reports multi-robot/external-axis low-speed controller verification boundaries", () => {
const project = sampleOlpProject();
const report = compareControllerLowSpeedRun([
{
pointId: "p01",
offline: [500, 0, 0, 0, 0, 0],
measured: [501, 0, 0, 0.1, 0, 0],
speedPercent: 10
},
{
pointId: "p02",
offline: [600, 0, 0, 0, 0, 0],
measured: [604, 0, 0, 0.5, 0, 0],
speedPercent: 10
}
], {
positionMm: 5,
orientationDeg: 1
});
expect(project.motionGroups[0]).toMatchObject({
robotIds: ["robot_1"],
externalAxisIds: ["track_1"]
});
expect(report).toMatchObject({
status: "pass",
speedPercent: 10,
maxPositionErrorMm: 4,
maxOrientationErrorDeg: 0.5
});
expect(report.boundaries).toEqual(expect.arrayContaining([
expect.stringContaining("does not open a live controller communication session"),
expect.stringContaining("does not include a full CAD kernel")
]));
});
});

View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import { postProcessAllBrands } from "../../src/grl/post/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import { compileSemanticProgram } from "../../src/grl/semantic/index.js";
import {
applyPatch,
pathOperationToGrl,
sampleOlpProject,
validateOlpProject,
type OlpProjectModel
} from "../../src/olp/index.js";
describe("OLP project model", () => {
it("creates a serializable station/resource/path/operation sample", () => {
const project = sampleOlpProject();
const restored = JSON.parse(JSON.stringify(project)) as OlpProjectModel;
const report = validateOlpProject(restored);
expect(restored.schemaVersion).toBe("olp/0.1");
expect(report).toEqual({ ok: true, issues: [] });
expect(restored.resources.robots[0]).toMatchObject({
id: "robot_1",
kind: "robot",
brand: "abb"
});
expect(restored.externalAxes[0]).toMatchObject({
id: "track_1",
axisKind: "linear"
});
expect(restored.motionGroups[0]).toMatchObject({
robotIds: ["robot_1"],
externalAxisIds: ["track_1"],
coordination: "synchronized"
});
});
it("applies brand import style object patches and validates references", () => {
const patched = applyPatch(sampleOlpProject(), [
{
op: "add",
path: "/targets/-",
value: {
id: "inspect",
name: "inspect",
kind: "pose",
robotId: "robot_1",
pose: [650, 25, 0, 0, 0, 0],
toolId: "tool_gripper",
frameId: "frame_fixture"
}
},
{
op: "add",
path: "/paths/0/points/-",
value: {
id: "p03",
name: "p03",
motion: "movel",
targetId: "inspect",
speedId: "v_linear",
zoneId: "z10"
}
}
]);
expect(validateOlpProject(patched).ok).toBe(true);
expect(patched.paths[0]?.points.map((point) => point.name)).toEqual(["p00", "p01", "p02", "p03"]);
});
it("generates stable parseable GRL from Path and Operation", () => {
const result = pathOperationToGrl(sampleOlpProject(), "pick_op", {
moduleName: "DemoCell",
style: "expanded"
});
const ast = parseGrl(result.text);
const ir = compileSemanticProgram(ast, {
startJoints: [0, 0, 0, 0, 0, 0],
sampleTime: 0.004
});
const post = postProcessAllBrands(ir);
expect(result.stableIds).toEqual({
targets: ["home", "pick", "place"],
points: ["p00", "p01", "p02"],
path: "pick_path",
operation: "pick_op"
});
expect(result.text).toContain("path pick_path");
expect(result.text).toContain("operation pick_op");
expect(result.program).toMatchObject({
id: "pick_op_grl",
language: "grl",
entryOperationIds: ["pick_op"]
});
expect(ir.operations[0]).toMatchObject({
operationId: "pick_op",
kind: "handling",
pathId: "pick_path"
});
expect(post.outputs.abb.text).toContain("MODULE DemoCell");
expect(post.outputs.fanuc.text).toContain("/PROG MAIN");
expect(post.outputs.kuka.text).toContain("DEF Main()");
});
it("reports broken schema references with stable issue codes", () => {
const project = sampleOlpProject();
project.operations[0]!.pathId = "missing_path";
expect(validateOlpProject(project)).toMatchObject({
ok: false,
issues: [expect.objectContaining({ code: "OLP_REF_PATH_MISSING" })]
});
});
});

View File

@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { postProcessBrand } from "../../src/grl/post/index.js";
import { parseGrl } from "../../src/grl/parser/index.js";
import { compileSemanticProgram } from "../../src/grl/semantic/index.js";
import { pathOperationToGrl, sampleOlpProject } from "../../src/olp/index.js";
import {
calibrationSummary,
createValidationReport,
exportCustomerDeliveryPackage,
exportValidationReportHtml,
serializeDeliveryPackage,
validateReportSchema
} from "../../src/reports/index.js";
const NOW = "2026-06-27T00:00:00.000Z";
describe("validation reports and customer delivery", () => {
it("creates stable validation report sections for reachability, cycle, IO/Wait, post, import, and calibration", () => {
const project = sampleOlpProject();
const report = createValidationReport({
id: "demo_validation",
project,
generatedAt: NOW,
pathValidation: {
ok: true,
reachable: true,
cycleTime: 4.2,
segmentReports: [],
diagnostics: []
},
ioWaitDiagnostics: [],
postDiagnostics: [],
importDiagnostics: [
{
severity: "info",
code: "IMPORT_OK",
message: "ABB import mapped to OLP",
sourceMap: { file: "imports/PickPlace.mod", line: 8 }
}
]
});
expect(validateReportSchema(report)).toEqual({ ok: true, issues: [] });
expect(report.status).toBe("pass");
expect(report.sections.map((section) => section.name)).toEqual([
"reachability",
"cycle_time",
"io_wait",
"post",
"import",
"collision",
"calibration"
]);
expect(report.sections.find((section) => section.name === "cycle_time")?.summary).toEqual({
cycleTime: 4.2,
trajectories: 0
});
expect(report.sourceMap).toEqual([
{
kind: "IMPORT_OK",
id: "IMPORT_OK-0",
file: "imports/PickPlace.mod",
line: 8
}
]);
});
it("exports openable HTML with summary, details, and source map payload", () => {
const report = createValidationReport({
id: "demo_validation",
project: sampleOlpProject(),
generatedAt: NOW,
diagnostics: [
{
severity: "info",
code: "REACH_OK",
message: "pick target reachable",
sourceMap: { file: "source/DemoCell.grl", line: 20 }
}
]
});
const html = exportValidationReportHtml(report);
expect(html).toContain("<!doctype html>");
expect(html).toContain("Status: warn");
expect(html).toContain("reachability");
expect(html).toContain("source/DemoCell.grl");
expect(html).toContain("validation-report-json");
});
it("exports customer delivery package with source, brand programs, IO map, reports, calibration, and trace", () => {
const project = sampleOlpProject();
const grl = pathOperationToGrl(project, "pick_op", { moduleName: "DemoCell" });
project.programs.push(grl.program);
const ir = compileSemanticProgram(parseGrl(grl.text), {
startJoints: [0, 0, 0, 0, 0, 0],
sampleTime: 0.004
});
const abb = postProcessBrand(ir, "abb");
const report = createValidationReport({
id: "demo_validation",
project,
generatedAt: NOW,
postDiagnostics: abb.report.map((issue) => ({
severity: issue.severity,
code: issue.code,
message: issue.message
})),
pathValidation: {
ok: true,
reachable: true,
cycleTime: 4.2,
segmentReports: [],
diagnostics: []
}
});
const pkg = exportCustomerDeliveryPackage({
project,
report,
trace: { samples: ["t=0 movej home"] },
brandPrograms: { [abb.filename]: abb.text },
ioMap: { "io.do[1]": "vacuum" }
});
const serialized = serializeDeliveryPackage(pkg);
expect(pkg.manifest).toEqual({
sourcePrograms: ["source/pick_op_grl.grl"],
brandPrograms: ["post/DemoCell.mod"],
reports: ["reports/demo_validation.html", "reports/demo_validation.json"],
calibrationFiles: ["calibration/calibrations.json"],
traceFiles: ["trace/trace.json"],
ioMaps: ["io/io_map.json"]
});
expect(pkg.files.map((file) => file.path)).toEqual([
"calibration/calibrations.json",
"io/io_map.json",
"post/DemoCell.mod",
"project/project.json",
"reports/demo_validation.html",
"reports/demo_validation.json",
"source/pick_op_grl.grl",
"trace/trace.json"
]);
expect(serialized).toContain("\"format\": \"customer-delivery/0.1\"");
expect(serialized).toContain("fnv1a32:");
expect(calibrationSummary(project.calibrations)).toEqual({ tcp: 1 });
});
});

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { IoImageRuntime, ioKey } from "../../src/runtime/index.js";
import type { IoReference, PulseInstruction, WaitInstruction } from "../../src/grl/ir/index.js";
const DO1: IoReference = { domain: "do", index: 1, raw: "io.do[1]" };
const DI1: IoReference = { domain: "di", index: 1, raw: "io.di[1]" };
const DI2: IoReference = { domain: "di", index: 2, raw: "io.di[2]" };
const AI1: IoReference = { domain: "ai", index: 1, raw: "io.ai[1]" };
describe("virtual IO image, waits, pulses, edges, and scripts", () => {
it("enforces write permissions and records IO events", () => {
const io = new IoImageRuntime({
permissions: [
{ writer: "program", domains: ["do"], access: "write" },
{ writer: "script", domains: ["di", "ai"], access: "write" }
]
});
expect(io.write(DO1, true, "program")).toMatchObject({ kind: "write", value: true });
expect(io.write(DI1, true, "program")).toBeUndefined();
expect(io.snapshot()).toMatchObject({
image: { [ioKey(DO1)]: true },
diagnostics: [expect.objectContaining({ code: "VC_IO_PERMISSION_DENIED" })]
});
});
it("evaluates waits, timeouts, on_timeout hold-stop, and edge conditions", () => {
const io = new IoImageRuntime({
permissions: [{ writer: "script", domains: ["di", "ai"], access: "write" }]
});
const wait: WaitInstruction = {
kind: "WAIT",
condition: "all ( io . di [ 1 ] == true , io . di [ 2 ] == false )",
timeout: 1,
onTimeout: { kind: "call", value: "recover" }
};
expect(io.evaluateWait(wait, 0)).toMatchObject({ status: "waiting" });
expect(io.evaluateWait(wait, 1)).toMatchObject({
status: "hold-stop",
onTimeout: { kind: "call", value: "recover" },
diagnostic: { code: "VC_WAIT_TIMEOUT" }
});
io.write(DI1, true, "script");
io.write(DI2, false, "script");
expect(io.evaluateWait(wait, 0)).toMatchObject({ status: "satisfied" });
expect(io.matchesCondition("rising ( io . di [ 1 ] )")).toBe(true);
io.write(DI1, false, "script");
expect(io.matchesCondition("falling ( io . di [ 1 ] )")).toBe(true);
io.write(AI1, 5, "script");
expect(io.matchesCondition("changed ( io . ai [ 1 ] )")).toBe(true);
});
it("auto-resets pulses and executes delayed IO scripts", () => {
const io = new IoImageRuntime({
permissions: [
{ writer: "program", domains: ["do"], access: "write" },
{ writer: "script", domains: ["di"], access: "write" }
]
});
const pulse: PulseInstruction = {
kind: "PULSE",
target: DO1,
duration: 0.2,
trace: [
{ time: 0, action: "set", target: DO1, value: true },
{ time: 0.2, action: "reset", target: DO1, value: false }
]
};
io.executePulse(pulse);
expect(io.read(DO1)).toBe(true);
io.advance(0.2);
expect(io.read(DO1)).toBe(false);
io.addScript("part-arrival", [{ delay: 0.5, target: DI1, value: true }]);
io.advance(0.49);
expect(io.read(DI1)).toBeUndefined();
io.advance(0.01);
expect(io.read(DI1)).toBe(true);
expect(io.snapshot().events.map((event) => event.kind)).toEqual([
"pulse_set",
"pulse_reset",
"script"
]);
});
});

View File

@@ -0,0 +1,157 @@
import { describe, expect, it } from "vitest";
import type { SemanticProgramIr } from "../../src/grl/ir/index.js";
import type { PathPlanRequest, PathPlanResult, TrajectoryPoint } from "../../src/kdl/types.js";
import { KdlRuntimeBridge, MotionQueue, type MotionPlanner } from "../../src/runtime/index.js";
const POSE = {
position: [0, 0, 0] as [number, number, number],
quaternion: [0, 0, 0, 1] as [number, number, number, number]
};
function point(index: number, time: number, joints: number[], segmentId: string): TrajectoryPoint {
return {
index,
time,
dt: index === 0 ? 0 : time,
s: time,
sd: 1,
sdd: 0,
joints,
jointVelocity: joints.map(() => 0),
jointAcceleration: joints.map(() => 0),
flange: POSE,
tcp: POSE,
motion: "MOVEJ",
segmentId,
diagnostics: []
};
}
function pathResult(request: PathPlanRequest): PathPlanResult {
return {
ok: true,
duration: 1,
segments: [],
points: [point(0, 0, request.startJoints, request.segments[0]?.id ?? "p0"), point(1, 1, [1], request.segments[0]?.id ?? "p0")],
diagnostics: []
};
}
function program(): SemanticProgramIr {
return {
moduleName: "Main",
symbols: [],
semanticChecks: [],
diagnostics: [],
sourceMap: [
{ kind: "path_point", id: "p0", pathId: "pick_path", pointId: "p0", sourceMap: { line: 10 } },
{ kind: "operation_action", id: "pick_op_start_action_0", operationId: "pick_op", sourceMap: { line: 20 } }
],
procedures: [
{
name: "main",
instructions: [
{ kind: "RUN_OPERATION", operationId: "pick_op", sourceMap: { line: 30 } }
]
}
],
paths: [
{
pathId: "pick_path",
request: {
pathId: "pick_path",
startJoints: [0],
sampleTime: 0.1,
segments: [
{
id: "p0",
motion: "MOVEJ",
target: { joints: [1] },
speed: { kind: "joint_abs", velocity: 1 },
zone: { kind: "fine" },
sourceMap: { line: 10 }
}
]
},
motions: [
{
kind: "MOVEJ",
id: "p0",
pathId: "pick_path",
pointId: "p0",
target: { joints: [1] },
speed: { kind: "joint_abs", velocity: 1 },
zone: { kind: "fine" },
sourceMap: { line: 10 },
source: { brand: { vendor: "ABB", file: "cell.mod", line: 42 } }
}
],
events: []
}
],
operations: [
{
operationId: "pick_op",
kind: "handling",
pathId: "pick_path",
process: {},
startActions: [
{
kind: "ACTION",
actionKind: "start_action",
operationId: "pick_op",
statement: "io.do[1] = true",
sourceMap: { line: 20 }
}
],
endActions: [
{
kind: "ACTION",
actionKind: "end_action",
operationId: "pick_op",
statement: "io.do[1] = false",
sourceMap: { line: 22 }
}
]
}
],
kdlBridge: {
motionRequests: [],
pathRequests: []
}
};
}
describe("motion queue and KDL runtime bridge", () => {
it("preserves operation/path/action sources and samples planned paths by virtual time", async () => {
const plannerCalls: PathPlanRequest[] = [];
const planner: MotionPlanner = {
planPath: (_handle, request) => {
plannerCalls.push(request);
return pathResult(request);
}
};
const queue = new MotionQueue({ startJoints: [0], sampleTime: 0.1, planner, robotHandle: 7 });
const ir = program();
const bridge = new KdlRuntimeBridge(ir, queue);
const items = bridge.enqueueInstruction(ir.procedures[0]!.instructions[0]!);
expect(items.map((item) => item.kind)).toEqual(["action", "path", "action"]);
expect(items[0]).toMatchObject({ source: { operationId: "pick_op", sourceMap: { line: 20 } } });
expect(items[1]).toMatchObject({ source: { pathId: "pick_path", operationId: "pick_op" } });
await queue.planAll();
expect(plannerCalls).toHaveLength(1);
expect(plannerCalls[0]).toMatchObject({ pathId: "pick_path", startJoints: [0] });
expect(queue.advance(0.5)).toMatchObject({
itemId: items[1]!.id,
localTime: 0.5,
point: { joints: [1] },
source: { pathId: "pick_path", operationId: "pick_op" }
});
expect(queue.snapshot().items[1]).toMatchObject({
planned: { ok: true, duration: 1 }
});
});
});

View File

@@ -0,0 +1,195 @@
import { describe, expect, it } from "vitest";
import type { SemanticProgramIr } from "../../src/grl/ir/index.js";
import type { PathPlanResult, TrajectoryPoint } from "../../src/kdl/types.js";
import { IrExecutionRuntime } from "../../src/controller/index.js";
import { IoImageRuntime, MotionQueue, type MotionPlanner } from "../../src/runtime/index.js";
import { DebugFacade, WorkbenchFacade } from "../../src/workbench/index.js";
const POSE = {
position: [0, 0, 0] as [number, number, number],
quaternion: [0, 0, 0, 1] as [number, number, number, number]
};
function point(index: number, time: number, joints: number[]): TrajectoryPoint {
return {
index,
time,
dt: time,
s: time,
sd: 1,
sdd: 0,
joints,
jointVelocity: [0],
jointAcceleration: [0],
flange: POSE,
tcp: POSE,
motion: "MOVEJ",
diagnostics: []
};
}
function program(): SemanticProgramIr {
return {
moduleName: "Main",
symbols: [{ kind: "path", name: "path1" }, { kind: "operation", name: "op1" }],
semanticChecks: [],
diagnostics: [],
sourceMap: [
{ kind: "RUN_PATH", id: "run_path", procedureId: "main", pathId: "path1", sourceMap: { file: "main.grl", line: 5 } },
{ kind: "path_point", id: "p1", pathId: "path1", pointId: "p1", sourceMap: { file: "main.grl", line: 10 } },
{ kind: "operation_action", id: "op1_start_action_0", operationId: "op1", sourceMap: { file: "main.grl", line: 20 } }
],
procedures: [
{
name: "main",
sourceMap: { file: "main.grl", line: 1 },
instructions: [
{ kind: "RAW_STATEMENT", text: "part_ready = true", sourceMap: { file: "main.grl", line: 4 } },
{ kind: "RUN_PATH", pathId: "path1", sourceMap: { file: "main.grl", line: 5 } }
]
}
],
paths: [
{
pathId: "path1",
request: {
pathId: "path1",
startJoints: [0],
sampleTime: 0.1,
segments: [
{
id: "p1",
motion: "MOVEJ",
target: { joints: [1] },
speed: { kind: "joint_abs", velocity: 1 },
zone: { kind: "fine" },
sourceMap: { file: "main.grl", line: 10 }
}
]
},
motions: [
{
kind: "MOVEJ",
id: "p1",
pathId: "path1",
pointId: "p1",
target: { joints: [1] },
speed: { kind: "joint_abs", velocity: 1 },
zone: { kind: "fine" },
sourceMap: { file: "main.grl", line: 10 },
source: { brand: { vendor: "KUKA", file: "src.src", line: 99 } }
}
],
events: []
}
],
operations: [
{
operationId: "op1",
kind: "handling",
pathId: "path1",
process: {},
startActions: [],
endActions: []
}
],
kdlBridge: { motionRequests: [], pathRequests: [] }
};
}
describe("debug and workbench facades", () => {
it("models breakpoints, motion breakpoints, watches, replay, and cross-source lookup", async () => {
const ir = program();
const runtime = new IrExecutionRuntime();
runtime.load(ir);
runtime.step();
const planner: MotionPlanner = {
planPath: (_handle, request): PathPlanResult => ({
ok: true,
duration: 1,
segments: [],
points: [point(0, 0, request.startJoints), point(1, 1, [1])],
diagnostics: []
})
};
const queue = new MotionQueue({ startJoints: [0], sampleTime: 0.1, planner });
const item = queue.enqueueRunPath({ kind: "RUN_PATH", pathId: "path1", sourceMap: { file: "main.grl", line: 5 } }, ir.paths[0]!);
await queue.planAll();
queue.advance(1);
const io = new IoImageRuntime();
const debug = new DebugFacade({ runtime, motionQueue: queue, io, program: ir });
debug.addBreakpoint({ id: "bp-run-path", procedure: "main", source: { line: 5 }, enabled: true });
debug.addMotionBreakpoint({ id: "mb-path", pathId: "path1", enabled: true });
const watch = debug.addWatch("watch-ready", "part_ready");
expect(watch).toMatchObject({ value: true, source: { sourceMap: { line: 5 } } });
expect(debug.checkMotionBreakpoint(item)).toMatchObject({ id: "mb-path" });
expect(debug.locatePathPoint("path1", "p1")).toMatchObject({ sourceMap: { line: 10 } });
expect(debug.locateOperation("op1")).toMatchObject([{ sourceMap: { line: 20 } }]);
expect(debug.playbackAt(1)).toMatchObject({ point: { joints: [1] } });
expect(debug.replayTrace()).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "load" })]));
expect(debug.snapshot()).toMatchObject({
breakpoints: [expect.objectContaining({ id: "bp-run-path" })],
motionBreakpoints: [expect.objectContaining({ id: "mb-path" })],
watches: [expect.objectContaining({ id: "watch-ready", value: true })]
});
});
it("builds object tree, editor, teach pendant, IO panel, and report entries", async () => {
const ir = program();
const runtime = new IrExecutionRuntime();
runtime.load(ir);
runtime.step();
const queue = new MotionQueue({
startJoints: [0],
sampleTime: 0.1,
planner: {
planPath: (_handle, request): PathPlanResult => ({
ok: true,
duration: 1,
segments: [],
points: [point(0, 0, request.startJoints), point(1, 1, [1])],
diagnostics: []
})
}
});
queue.enqueueRunPath({ kind: "RUN_PATH", pathId: "path1" }, ir.paths[0]!);
await queue.planAll();
queue.advance(1);
const io = new IoImageRuntime();
io.write({ domain: "do", index: 1, raw: "io.do[1]" }, true);
const workbench = new WorkbenchFacade();
const snapshot = workbench.snapshot({
program: ir,
runtime: runtime.snapshot(),
io: io.snapshot(),
motion: queue.snapshot(),
controllerState: "paused"
});
expect(snapshot.objectTree[0]).toMatchObject({
kind: "station",
children: [
expect.objectContaining({ id: "programs" }),
expect.objectContaining({ id: "paths" }),
expect.objectContaining({ id: "operations" }),
expect.objectContaining({ id: "reports" })
]
});
expect(snapshot.editor).toMatchObject({ activeFile: "main.grl", cursor: { line: 5 } });
expect(snapshot.teachPendant).toMatchObject({ state: "paused", procedure: "main", joints: [1] });
expect(snapshot.ioPanel.image).toMatchObject({ "io.do[1]": true });
expect(snapshot.reports.map((report) => report.kind)).toEqual([
"reachability",
"cycle_time",
"io_wait",
"post",
"import"
]);
});
});

View File

@@ -0,0 +1,140 @@
import { describe, expect, it } from "vitest";
import { sampleOlpProject } from "../../src/olp/index.js";
import {
MemoryWorkspaceStorage,
detectWorkspaceDamage,
exportWorkspaceBundle,
importWorkspaceBundle,
initializeWorkspace,
migrateManifest,
readJson,
readManifest,
readProjectModel,
restoreWorkspace,
snapshotWorkspace,
writeJson,
writeProjectModel
} from "../../src/workspace/index.js";
const NOW = "2026-06-27T00:00:00.000Z";
describe("Workspace storage", () => {
it("creates project manifest and conventional project layout", async () => {
const storage = new MemoryWorkspaceStorage();
const manifest = await initializeWorkspace(storage, {
projectId: "demo_cell",
name: "Demo Cell",
model: sampleOlpProject(),
now: NOW
});
expect(manifest.entrypoints.model).toBe("model/olp-project.json");
expect(await storage.list()).toEqual(["model/olp-project.json", "project.json"]);
expect(await readManifest(storage)).toEqual(manifest);
expect((await readProjectModel(storage)).project.id).toBe("demo_cell");
});
it("supports text/json read-write-delete with OPFS-like async storage", async () => {
const storage = new MemoryWorkspaceStorage();
await storage.writeText("reports/readme.txt", "hello");
await writeJson(storage, "imports/report.json", { ok: true, count: 2 });
expect(await storage.readText("reports/readme.txt")).toBe("hello");
expect(await readJson(storage, "imports/report.json")).toEqual({ ok: true, count: 2 });
await storage.delete("reports/readme.txt");
expect(await storage.exists("reports/readme.txt")).toBe(false);
});
it("snapshots and restores an equivalent workspace", async () => {
const source = new MemoryWorkspaceStorage();
await initializeWorkspace(source, {
projectId: "demo_cell",
name: "Demo Cell",
model: sampleOlpProject(),
now: NOW
});
await source.writeText("programs/main.grl", "language grl 0.1\n");
const snapshot = await snapshotWorkspace(source);
const target = new MemoryWorkspaceStorage({ "old.txt": "delete me" });
await restoreWorkspace(target, snapshot);
expect(await target.list()).toEqual(await source.list());
expect(await snapshotWorkspace(target)).toEqual(snapshot);
});
it("exports and imports a stable JSON bundle with checksum", async () => {
const source = new MemoryWorkspaceStorage();
await initializeWorkspace(source, {
projectId: "demo_cell",
name: "Demo Cell",
model: sampleOlpProject(),
now: NOW
});
await source.writeText("programs/main.grl", "language grl 0.1\n");
const bundle = await exportWorkspaceBundle(source);
const target = new MemoryWorkspaceStorage();
const report = await importWorkspaceBundle(target, bundle);
expect(report).toEqual({ ok: true, damages: [] });
expect(await snapshotWorkspace(target)).toEqual(await snapshotWorkspace(source));
});
it("migrates legacy manifests and detects damaged workspaces", async () => {
expect(migrateManifest({
schemaVersion: 0,
projectId: "legacy",
name: "Legacy",
createdAt: NOW,
updatedAt: NOW,
modelPath: "legacy/model.json"
})).toMatchObject({
schemaVersion: 1,
projectId: "legacy",
entrypoints: { model: "legacy/model.json" }
});
const storage = new MemoryWorkspaceStorage();
await initializeWorkspace(storage, {
projectId: "demo_cell",
name: "Demo Cell",
model: sampleOlpProject(),
now: NOW
});
const checksum = (await snapshotWorkspace(storage)).checksum;
const model = await readProjectModel(storage);
model.operations[0]!.pathId = "missing_path";
await writeProjectModel(storage, model, NOW);
expect(await detectWorkspaceDamage(storage, checksum)).toMatchObject({
ok: false,
damages: [
expect.objectContaining({ code: "WORKSPACE_MODEL_INVALID" }),
expect.objectContaining({ code: "WORKSPACE_CHECKSUM_MISMATCH" })
]
});
});
it("rejects tampered bundles before import", async () => {
const source = new MemoryWorkspaceStorage();
await initializeWorkspace(source, {
projectId: "demo_cell",
name: "Demo Cell",
model: sampleOlpProject(),
now: NOW
});
const tampered = JSON.parse(await exportWorkspaceBundle(source)) as {
files: Record<string, string>;
};
tampered.files["project.json"] = "{}";
const target = new MemoryWorkspaceStorage();
expect(await importWorkspaceBundle(target, JSON.stringify(tampered))).toMatchObject({
ok: false,
damages: [expect.objectContaining({ code: "WORKSPACE_CHECKSUM_MISMATCH" })]
});
expect(await target.list()).toEqual([]);
});
});