Upload project files
This commit is contained in:
127
kdl-wasm/web/tests/controller/virtualController.test.ts
Normal file
127
kdl-wasm/web/tests/controller/virtualController.test.ts
Normal 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" } })
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
28
kdl-wasm/web/tests/docs/flowAssetCoverage.test.ts
Normal file
28
kdl-wasm/web/tests/docs/flowAssetCoverage.test.ts
Normal 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"]) })
|
||||
]);
|
||||
});
|
||||
});
|
||||
9
kdl-wasm/web/tests/fixtures/abb120/programs/A120_00_Smoke.grl
vendored
Normal file
9
kdl-wasm/web/tests/fixtures/abb120/programs/A120_00_Smoke.grl
vendored
Normal 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
|
||||
22
kdl-wasm/web/tests/fixtures/abb120/programs/A120_10_JointPickPlace.grl
vendored
Normal file
22
kdl-wasm/web/tests/fixtures/abb120/programs/A120_10_JointPickPlace.grl
vendored
Normal 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
|
||||
20
kdl-wasm/web/tests/fixtures/abb120/programs/A120_20_CartesianBlend.grl
vendored
Normal file
20
kdl-wasm/web/tests/fixtures/abb120/programs/A120_20_CartesianBlend.grl
vendored
Normal 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
|
||||
17
kdl-wasm/web/tests/fixtures/abb120/programs/A120_30_IOWaitPulse.grl
vendored
Normal file
17
kdl-wasm/web/tests/fixtures/abb120/programs/A120_30_IOWaitPulse.grl
vendored
Normal 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
|
||||
12
kdl-wasm/web/tests/fixtures/abb120/programs/A120_40_ErrorDiagnostics.grl
vendored
Normal file
12
kdl-wasm/web/tests/fixtures/abb120/programs/A120_40_ErrorDiagnostics.grl
vendored
Normal 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
|
||||
25
kdl-wasm/web/tests/fixtures/abb120/programs/A120_50_OperationProcess.grl
vendored
Normal file
25
kdl-wasm/web/tests/fixtures/abb120/programs/A120_50_OperationProcess.grl
vendored
Normal 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
|
||||
11
kdl-wasm/web/tests/fixtures/abbIrb120.ts
vendored
Normal file
11
kdl-wasm/web/tests/fixtures/abbIrb120.ts
vendored
Normal 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";
|
||||
141
kdl-wasm/web/tests/grl/expressionCompile.test.ts
Normal file
141
kdl-wasm/web/tests/grl/expressionCompile.test.ts
Normal 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 })
|
||||
]);
|
||||
});
|
||||
});
|
||||
144
kdl-wasm/web/tests/importers/brandImport.test.ts
Normal file
144
kdl-wasm/web/tests/importers/brandImport.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
415
kdl-wasm/web/tests/integration/abb120Programs.test.ts
Normal file
415
kdl-wasm/web/tests/integration/abb120Programs.test.ts
Normal 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"
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
82
kdl-wasm/web/tests/integration/abb120SuiteArtifacts.test.ts
Normal file
82
kdl-wasm/web/tests/integration/abb120SuiteArtifacts.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
146
kdl-wasm/web/tests/olp/geometry.test.ts
Normal file
146
kdl-wasm/web/tests/olp/geometry.test.ts
Normal 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")
|
||||
]));
|
||||
});
|
||||
});
|
||||
114
kdl-wasm/web/tests/olp/model.test.ts
Normal file
114
kdl-wasm/web/tests/olp/model.test.ts
Normal 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" })]
|
||||
});
|
||||
});
|
||||
});
|
||||
149
kdl-wasm/web/tests/reports/report.test.ts
Normal file
149
kdl-wasm/web/tests/reports/report.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
88
kdl-wasm/web/tests/runtime/ioRuntime.test.ts
Normal file
88
kdl-wasm/web/tests/runtime/ioRuntime.test.ts
Normal 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"
|
||||
]);
|
||||
});
|
||||
});
|
||||
157
kdl-wasm/web/tests/runtime/motionQueue.test.ts
Normal file
157
kdl-wasm/web/tests/runtime/motionQueue.test.ts
Normal 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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
195
kdl-wasm/web/tests/workbench/debugWorkbench.test.ts
Normal file
195
kdl-wasm/web/tests/workbench/debugWorkbench.test.ts
Normal 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"
|
||||
]);
|
||||
});
|
||||
});
|
||||
140
kdl-wasm/web/tests/workspace/storage.test.ts
Normal file
140
kdl-wasm/web/tests/workspace/storage.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user