feat: Implement digital signatures for participant consent and introduce study forms management.

This commit is contained in:
2026-03-02 10:51:20 -05:00
parent 61af467cc8
commit 0051946bde
172 changed files with 12612 additions and 9461 deletions

View File

@@ -1,110 +1,118 @@
import { describe, it, expect } from "vitest";
import { convertStepsToDatabase, convertDatabaseToSteps } from "../block-converter";
import {
convertStepsToDatabase,
convertDatabaseToSteps,
} from "../block-converter";
import type { ExperimentStep, ExperimentAction } from "../types";
// Mock Action
const branchAction: ExperimentAction = {
id: "act-branch-1",
name: "Decision",
type: "branch",
category: "control",
parameters: {},
source: { kind: "core", baseActionId: "branch" },
execution: { transport: "internal" }
id: "act-branch-1",
name: "Decision",
type: "branch",
category: "control",
parameters: {},
source: { kind: "core", baseActionId: "branch" },
execution: { transport: "internal" },
};
describe("Control Flow Persistence", () => {
it("should persist conditional branching options", () => {
const originalSteps: ExperimentStep[] = [
{
id: "step-1",
name: "Question",
type: "conditional",
order: 0,
trigger: {
type: "trial_start",
conditions: {
options: [
{ label: "Yes", nextStepIndex: 1, variant: "default" },
{ label: "No", nextStepIndex: 2, variant: "destructive" }
]
}
},
actions: [branchAction],
expanded: true
it("should persist conditional branching options", () => {
const originalSteps: ExperimentStep[] = [
{
id: "step-1",
name: "Question",
type: "conditional",
order: 0,
trigger: {
type: "trial_start",
conditions: {
options: [
{ label: "Yes", nextStepIndex: 1, variant: "default" },
{ label: "No", nextStepIndex: 2, variant: "destructive" },
],
},
},
actions: [branchAction],
expanded: true,
},
{
id: "step-2",
name: "Path A",
type: "sequential",
order: 1,
trigger: { type: "previous_step", conditions: {} },
actions: [],
expanded: true,
},
{
id: "step-3",
name: "Path B",
type: "sequential",
order: 2,
trigger: { type: "previous_step", conditions: {} },
actions: [],
expanded: true,
},
];
// Simulate Save
const dbRows = convertStepsToDatabase(originalSteps);
// START DEBUG
// console.log("DB Rows Conditions:", JSON.stringify(dbRows[0].conditions, null, 2));
// END DEBUG
expect(dbRows[0]!.type).toBe("conditional");
expect((dbRows[0]!.conditions as any).options).toHaveLength(2);
// Simulate Load
const hydratedSteps = convertDatabaseToSteps(dbRows);
expect(hydratedSteps[0]!.type).toBe("conditional");
expect((hydratedSteps[0]!.trigger.conditions as any).options).toHaveLength(
2,
);
expect((hydratedSteps[0]!.trigger.conditions as any).options[0].label).toBe(
"Yes",
);
});
it("should persist loop configuration", () => {
const originalSteps: ExperimentStep[] = [
{
id: "step-loop-1",
name: "Repeat Task",
type: "loop",
order: 0,
trigger: {
type: "trial_start",
conditions: {
loop: {
iterations: 5,
requireApproval: false,
},
{
id: "step-2",
name: "Path A",
type: "sequential",
order: 1,
trigger: { type: "previous_step", conditions: {} },
actions: [],
expanded: true
},
{
id: "step-3",
name: "Path B",
type: "sequential",
order: 2,
trigger: { type: "previous_step", conditions: {} },
actions: [],
expanded: true
}
];
},
},
actions: [],
expanded: true,
},
];
// Simulate Save
const dbRows = convertStepsToDatabase(originalSteps);
// Simulate Save
const dbRows = convertStepsToDatabase(originalSteps);
// START DEBUG
// console.log("DB Rows Conditions:", JSON.stringify(dbRows[0].conditions, null, 2));
// END DEBUG
// Note: 'loop' type is mapped to 'conditional' in DB, but detailed conditions should survive
expect(dbRows[0]!.type).toBe("conditional");
expect((dbRows[0]!.conditions as any).loop.iterations).toBe(5);
expect(dbRows[0]!.type).toBe("conditional");
expect((dbRows[0]!.conditions as any).options).toHaveLength(2);
// Simulate Load
const hydratedSteps = convertDatabaseToSteps(dbRows);
// Simulate Load
const hydratedSteps = convertDatabaseToSteps(dbRows);
expect(hydratedSteps[0]!.type).toBe("conditional");
expect((hydratedSteps[0]!.trigger.conditions as any).options).toHaveLength(2);
expect((hydratedSteps[0]!.trigger.conditions as any).options[0].label).toBe("Yes");
});
it("should persist loop configuration", () => {
const originalSteps: ExperimentStep[] = [
{
id: "step-loop-1",
name: "Repeat Task",
type: "loop",
order: 0,
trigger: {
type: "trial_start",
conditions: {
loop: {
iterations: 5,
requireApproval: false
}
}
},
actions: [],
expanded: true
}
];
// Simulate Save
const dbRows = convertStepsToDatabase(originalSteps);
// Note: 'loop' type is mapped to 'conditional' in DB, but detailed conditions should survive
expect(dbRows[0]!.type).toBe("conditional");
expect((dbRows[0]!.conditions as any).loop.iterations).toBe(5);
// Simulate Load
const hydratedSteps = convertDatabaseToSteps(dbRows);
// Checking data integrity
expect((hydratedSteps[0]!.trigger.conditions as any).loop).toBeDefined();
expect((hydratedSteps[0]!.trigger.conditions as any).loop.iterations).toBe(5);
});
// Checking data integrity
expect((hydratedSteps[0]!.trigger.conditions as any).loop).toBeDefined();
expect((hydratedSteps[0]!.trigger.conditions as any).loop.iterations).toBe(
5,
);
});
});

View File

@@ -1,108 +1,119 @@
import { describe, it, expect } from "vitest";
import { Hashing } from "../../../components/experiments/designer/state/hashing";
import type { ExperimentStep, ExperimentAction } from "../../experiment-designer/types";
import type {
ExperimentStep,
ExperimentAction,
} from "../../experiment-designer/types";
describe("Hashing Utilities", () => {
describe("canonicalize", () => {
it("should sort object keys", () => {
const obj1 = { b: 2, a: 1 };
const obj2 = { a: 1, b: 2 };
expect(JSON.stringify(Hashing.canonicalize(obj1)))
.toBe(JSON.stringify(Hashing.canonicalize(obj2)));
});
it("should remove undefined values", () => {
const obj = { a: 1, b: undefined, c: null };
const canonical = Hashing.canonicalize(obj) as any;
expect(canonical).toHaveProperty("a");
expect(canonical).toHaveProperty("c"); // null is preserved
expect(canonical).not.toHaveProperty("b");
});
it("should preserve array order", () => {
const arr = [3, 1, 2];
const canonical = Hashing.canonicalize(arr);
expect(canonical).toEqual([3, 1, 2]);
});
describe("canonicalize", () => {
it("should sort object keys", () => {
const obj1 = { b: 2, a: 1 };
const obj2 = { a: 1, b: 2 };
expect(JSON.stringify(Hashing.canonicalize(obj1))).toBe(
JSON.stringify(Hashing.canonicalize(obj2)),
);
});
describe("computeDesignHash", () => {
const step1: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true
};
const step2: ExperimentStep = {
id: "step-2",
name: "Step 2",
type: "sequential",
order: 1,
trigger: { type: "previous_step", conditions: {} },
actions: [],
expanded: true
};
it("should produce deterministic hash regardless of input array order", async () => {
const hash1 = await Hashing.computeDesignHash([step1, step2]);
const hash2 = await Hashing.computeDesignHash([step2, step1]);
expect(hash1).toBe(hash2);
});
it("should change hash when step content changes", async () => {
const hash1 = await Hashing.computeDesignHash([step1]);
const modifiedStep = { ...step1, name: "Modified Name" };
const hash2 = await Hashing.computeDesignHash([modifiedStep]);
expect(hash1).not.toBe(hash2);
});
it("should change hash when parameters change if included", async () => {
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: { message: "A" },
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" }
};
const stepWithAction = { ...step1, actions: [action] };
const hash1 = await Hashing.computeDesignHash([stepWithAction], { includeParameterValues: true });
const modifiedAction = { ...action, parameters: { message: "B" } };
const stepModified = { ...step1, actions: [modifiedAction] };
const hash2 = await Hashing.computeDesignHash([stepModified], { includeParameterValues: true });
expect(hash1).not.toBe(hash2);
});
it("should NOT change hash when parameters change if excluded", async () => {
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: { message: "A" },
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" }
};
const stepWithAction = { ...step1, actions: [action] };
const hash1 = await Hashing.computeDesignHash([stepWithAction], { includeParameterValues: false });
const modifiedAction = { ...action, parameters: { message: "B" } };
const stepModified = { ...step1, actions: [modifiedAction] };
const hash2 = await Hashing.computeDesignHash([stepModified], { includeParameterValues: false });
expect(hash1).toBe(hash2);
});
it("should remove undefined values", () => {
const obj = { a: 1, b: undefined, c: null };
const canonical = Hashing.canonicalize(obj) as any;
expect(canonical).toHaveProperty("a");
expect(canonical).toHaveProperty("c"); // null is preserved
expect(canonical).not.toHaveProperty("b");
});
it("should preserve array order", () => {
const arr = [3, 1, 2];
const canonical = Hashing.canonicalize(arr);
expect(canonical).toEqual([3, 1, 2]);
});
});
describe("computeDesignHash", () => {
const step1: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
};
const step2: ExperimentStep = {
id: "step-2",
name: "Step 2",
type: "sequential",
order: 1,
trigger: { type: "previous_step", conditions: {} },
actions: [],
expanded: true,
};
it("should produce deterministic hash regardless of input array order", async () => {
const hash1 = await Hashing.computeDesignHash([step1, step2]);
const hash2 = await Hashing.computeDesignHash([step2, step1]);
expect(hash1).toBe(hash2);
});
it("should change hash when step content changes", async () => {
const hash1 = await Hashing.computeDesignHash([step1]);
const modifiedStep = { ...step1, name: "Modified Name" };
const hash2 = await Hashing.computeDesignHash([modifiedStep]);
expect(hash1).not.toBe(hash2);
});
it("should change hash when parameters change if included", async () => {
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: { message: "A" },
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" },
};
const stepWithAction = { ...step1, actions: [action] };
const hash1 = await Hashing.computeDesignHash([stepWithAction], {
includeParameterValues: true,
});
const modifiedAction = { ...action, parameters: { message: "B" } };
const stepModified = { ...step1, actions: [modifiedAction] };
const hash2 = await Hashing.computeDesignHash([stepModified], {
includeParameterValues: true,
});
expect(hash1).not.toBe(hash2);
});
it("should NOT change hash when parameters change if excluded", async () => {
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: { message: "A" },
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" },
};
const stepWithAction = { ...step1, actions: [action] };
const hash1 = await Hashing.computeDesignHash([stepWithAction], {
includeParameterValues: false,
});
const modifiedAction = { ...action, parameters: { message: "B" } };
const stepModified = { ...step1, actions: [modifiedAction] };
const hash2 = await Hashing.computeDesignHash([stepModified], {
includeParameterValues: false,
});
expect(hash1).toBe(hash2);
});
});
});

View File

@@ -1,139 +1,141 @@
import { describe, it, expect, beforeEach } from "vitest";
import { createDesignerStore } from "../../../components/experiments/designer/state/store";
import type { ExperimentStep, ExperimentAction } from "../../experiment-designer/types";
import type {
ExperimentStep,
ExperimentAction,
} from "../../experiment-designer/types";
// Helper to create a store instance
// We need to bypass the actual hook usage since we are in a non-React env
const createTestStore = () => {
// Use the exported creator
return createDesignerStore({
initialSteps: []
});
// Use the exported creator
return createDesignerStore({
initialSteps: [],
});
};
describe("Designer Store Integration", () => {
let store: ReturnType<typeof createTestStore>;
let store: ReturnType<typeof createTestStore>;
beforeEach(() => {
store = createTestStore();
});
beforeEach(() => {
store = createTestStore();
});
it("should initialize with empty steps", () => {
expect(store.getState().steps).toHaveLength(0);
});
it("should initialize with empty steps", () => {
expect(store.getState().steps).toHaveLength(0);
});
it("should upsert a new step", () => {
const step: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true
};
it("should upsert a new step", () => {
const step: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
};
store.getState().upsertStep(step);
expect(store.getState().steps).toHaveLength(1);
expect(store.getState().steps[0]!.id).toBe("step-1");
});
store.getState().upsertStep(step);
expect(store.getState().steps).toHaveLength(1);
expect(store.getState().steps[0]!.id).toBe("step-1");
});
it("should update an existing step", () => {
const step: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true
};
store.getState().upsertStep(step);
it("should update an existing step", () => {
const step: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
};
store.getState().upsertStep(step);
const updatedStep = { ...step, name: "Updated Step" };
store.getState().upsertStep(updatedStep);
const updatedStep = { ...step, name: "Updated Step" };
store.getState().upsertStep(updatedStep);
expect(store.getState().steps).toHaveLength(1);
expect(store.getState().steps[0]!.name).toBe("Updated Step");
});
expect(store.getState().steps).toHaveLength(1);
expect(store.getState().steps[0]!.name).toBe("Updated Step");
});
it("should remove a step", () => {
const step: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true
};
store.getState().upsertStep(step);
store.getState().removeStep(step.id);
expect(store.getState().steps).toHaveLength(0);
});
it("should remove a step", () => {
const step: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
};
store.getState().upsertStep(step);
store.getState().removeStep(step.id);
expect(store.getState().steps).toHaveLength(0);
});
it("should reorder steps", () => {
const step1: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true
};
const step2: ExperimentStep = {
id: "step-2",
name: "Step 2",
type: "sequential",
order: 1,
trigger: { type: "previous_step", conditions: {} },
actions: [],
expanded: true
};
it("should reorder steps", () => {
const step1: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
};
const step2: ExperimentStep = {
id: "step-2",
name: "Step 2",
type: "sequential",
order: 1,
trigger: { type: "previous_step", conditions: {} },
actions: [],
expanded: true,
};
store.getState().upsertStep(step1);
store.getState().upsertStep(step2);
store.getState().upsertStep(step1);
store.getState().upsertStep(step2);
// Move Step 1 to index 1 (swap)
store.getState().reorderStep(0, 1);
// Move Step 1 to index 1 (swap)
store.getState().reorderStep(0, 1);
const steps = store.getState().steps;
expect(steps[0]!.id).toBe("step-2");
expect(steps[1]!.id).toBe("step-1");
const steps = store.getState().steps;
expect(steps[0]!.id).toBe("step-2");
expect(steps[1]!.id).toBe("step-1");
// Orders should be updated
expect(steps[0]!.order).toBe(0);
expect(steps[1]!.order).toBe(1);
});
// Orders should be updated
expect(steps[0]!.order).toBe(0);
expect(steps[1]!.order).toBe(1);
});
it("should upsert an action into a step", () => {
const step: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true
};
store.getState().upsertStep(step);
it("should upsert an action into a step", () => {
const step: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
};
store.getState().upsertStep(step);
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: {},
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" }
};
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: {},
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" },
};
store.getState().upsertAction("step-1", action);
store.getState().upsertAction("step-1", action);
const storedStep = store.getState().steps[0];
expect(storedStep!.actions).toHaveLength(1);
expect(storedStep!.actions[0]!.id).toBe("act-1");
});
const storedStep = store.getState().steps[0];
expect(storedStep!.actions).toHaveLength(1);
expect(storedStep!.actions[0]!.id).toBe("act-1");
});
});

View File

@@ -1,125 +1,158 @@
import { describe, it, expect } from "vitest";
import { validateExperimentDesign, validateStructural, validateParameters } from "../../../components/experiments/designer/state/validators";
import type { ExperimentStep, ExperimentAction, ActionDefinition } from "../../experiment-designer/types";
import {
validateExperimentDesign,
validateStructural,
validateParameters,
} from "../../../components/experiments/designer/state/validators";
import type {
ExperimentStep,
ExperimentAction,
ActionDefinition,
} from "../../experiment-designer/types";
// Mock Data
const mockActionDef: ActionDefinition = {
id: "core.log",
name: "Log Info",
type: "log",
description: "Logs information",
category: "observation",
icon: "lucide-info",
color: "blue",
parameters: [
{ id: "message", name: "Message", type: "text", required: true },
{ id: "level", name: "Level", type: "select", options: ["info", "warn", "error"], value: "info" }
],
source: { kind: "core", baseActionId: "log" }
id: "core.log",
name: "Log Info",
type: "log",
description: "Logs information",
category: "observation",
icon: "lucide-info",
color: "blue",
parameters: [
{ id: "message", name: "Message", type: "text", required: true },
{
id: "level",
name: "Level",
type: "select",
options: ["info", "warn", "error"],
value: "info",
},
],
source: { kind: "core", baseActionId: "log" },
};
const validStep: ExperimentStep = {
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
description: "A valid step"
id: "step-1",
name: "Step 1",
type: "sequential",
order: 0,
trigger: { type: "trial_start", conditions: {} },
actions: [],
expanded: true,
description: "A valid step",
};
describe("Experiment Validators", () => {
describe("Structural Validation", () => {
it("should fail if experiment has no steps", () => {
const result = validateExperimentDesign([], { steps: [], actionDefinitions: [] });
expect(result.valid).toBe(false);
expect(result.issues[0]!.message).toContain("at least one step");
});
it("should fail if step name is empty", () => {
const step = { ...validStep, name: "" };
const issues = validateStructural([step], { steps: [step], actionDefinitions: [] });
expect(issues.some(i => i.field === "name" && i.severity === "error")).toBe(true);
});
it("should fail if step type is invalid", () => {
const step = { ...validStep, type: "magic_step" as any };
const issues = validateStructural([step], { steps: [step], actionDefinitions: [] });
expect(issues.some(i => i.field === "type" && i.severity === "error")).toBe(true);
});
describe("Structural Validation", () => {
it("should fail if experiment has no steps", () => {
const result = validateExperimentDesign([], {
steps: [],
actionDefinitions: [],
});
expect(result.valid).toBe(false);
expect(result.issues[0]!.message).toContain("at least one step");
});
describe("Parameter Validation", () => {
it("should fail if required parameter is missing", () => {
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: {}, // Missing 'message'
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" }
};
const step: ExperimentStep = { ...validStep, actions: [action] };
const issues = validateParameters([step], {
steps: [step],
actionDefinitions: [mockActionDef]
});
expect(issues.some(i => i.field === "parameters.message" && i.severity === "error")).toBe(true);
});
it("should pass if required parameter is present", () => {
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: { message: "Hello" },
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" }
};
const step: ExperimentStep = { ...validStep, actions: [action] };
const issues = validateParameters([step], {
steps: [step],
actionDefinitions: [mockActionDef]
});
// Should have 0 errors, maybe warnings but no parameter errors
const paramErrors = issues.filter(i => i.category === "parameter" && i.severity === "error");
expect(paramErrors).toHaveLength(0);
});
it("should validate number ranges", () => {
const rangeActionDef: ActionDefinition = {
...mockActionDef,
id: "math",
type: "math",
parameters: [{ id: "val", name: "Value", type: "number", min: 0, max: 10 }]
};
const action: ExperimentAction = {
id: "act-1",
type: "math",
name: "Math",
category: "observation",
parameters: { val: 15 }, // Too high
source: { kind: "core", baseActionId: "math" },
execution: { transport: "internal" }
};
const step: ExperimentStep = { ...validStep, actions: [action] };
const issues = validateParameters([step], {
steps: [step],
actionDefinitions: [rangeActionDef]
});
expect(issues[0]!.message).toContain("must be at most 10");
});
it("should fail if step name is empty", () => {
const step = { ...validStep, name: "" };
const issues = validateStructural([step], {
steps: [step],
actionDefinitions: [],
});
expect(
issues.some((i) => i.field === "name" && i.severity === "error"),
).toBe(true);
});
it("should fail if step type is invalid", () => {
const step = { ...validStep, type: "magic_step" as any };
const issues = validateStructural([step], {
steps: [step],
actionDefinitions: [],
});
expect(
issues.some((i) => i.field === "type" && i.severity === "error"),
).toBe(true);
});
});
describe("Parameter Validation", () => {
it("should fail if required parameter is missing", () => {
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: {}, // Missing 'message'
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" },
};
const step: ExperimentStep = { ...validStep, actions: [action] };
const issues = validateParameters([step], {
steps: [step],
actionDefinitions: [mockActionDef],
});
expect(
issues.some(
(i) => i.field === "parameters.message" && i.severity === "error",
),
).toBe(true);
});
it("should pass if required parameter is present", () => {
const action: ExperimentAction = {
id: "act-1",
type: "log",
name: "Log",
category: "observation",
parameters: { message: "Hello" },
source: { kind: "core", baseActionId: "log" },
execution: { transport: "internal" },
};
const step: ExperimentStep = { ...validStep, actions: [action] };
const issues = validateParameters([step], {
steps: [step],
actionDefinitions: [mockActionDef],
});
// Should have 0 errors, maybe warnings but no parameter errors
const paramErrors = issues.filter(
(i) => i.category === "parameter" && i.severity === "error",
);
expect(paramErrors).toHaveLength(0);
});
it("should validate number ranges", () => {
const rangeActionDef: ActionDefinition = {
...mockActionDef,
id: "math",
type: "math",
parameters: [
{ id: "val", name: "Value", type: "number", min: 0, max: 10 },
],
};
const action: ExperimentAction = {
id: "act-1",
type: "math",
name: "Math",
category: "observation",
parameters: { val: 15 }, // Too high
source: { kind: "core", baseActionId: "math" },
execution: { transport: "internal" },
};
const step: ExperimentStep = { ...validStep, actions: [action] };
const issues = validateParameters([step], {
steps: [step],
actionDefinitions: [rangeActionDef],
});
expect(issues[0]!.message).toContain("must be at most 10");
});
});
});

View File

@@ -144,7 +144,9 @@ export function convertActionToDatabase(
if (action.children && action.children.length > 0) {
// Recursively convert children for container actions (sequence, parallel, loop)
// Branch actions don't have children - they control step routing
parameters.children = action.children.map((child, idx) => convertActionToDatabase(child, idx));
parameters.children = action.children.map((child, idx) =>
convertActionToDatabase(child, idx),
);
}
return {
@@ -170,11 +172,13 @@ export function convertActionToDatabase(
// Reconstruct designer steps from database records
export function convertDatabaseToSteps(
dbSteps: any[] // Typing as any[] because Drizzle types are complex to import here without circular deps
dbSteps: any[], // Typing as any[] because Drizzle types are complex to import here without circular deps
): ExperimentStep[] {
// Paranoid Sort: Ensure steps are strictly ordered by index before assigning Triggers.
// This safeguards against API returning unsorted data.
const sortedSteps = [...dbSteps].sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0));
const sortedSteps = [...dbSteps].sort(
(a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0),
);
return sortedSteps.map((dbStep, idx) => {
return {
@@ -191,7 +195,7 @@ export function convertDatabaseToSteps(
},
expanded: true, // Default to expanded in designer
actions: (dbStep.actions || []).map((dbAction: any) =>
convertDatabaseToAction(dbAction)
convertDatabaseToAction(dbAction),
),
};
});
@@ -213,9 +217,12 @@ function mapDatabaseToStepType(type: string): ExperimentStep["type"] {
export function convertDatabaseToAction(dbAction: any): ExperimentAction {
// Reconstruct nested source object
const source: ExperimentAction["source"] = {
kind: (dbAction.sourceKind || dbAction.source_kind || "core") as "core" | "plugin",
kind: (dbAction.sourceKind || dbAction.source_kind || "core") as
| "core"
| "plugin",
pluginId: dbAction.pluginId || dbAction.plugin_id || undefined,
pluginVersion: dbAction.pluginVersion || dbAction.plugin_version || undefined,
pluginVersion:
dbAction.pluginVersion || dbAction.plugin_version || undefined,
robotId: dbAction.robotId || dbAction.robot_id || undefined,
baseActionId: dbAction.baseActionId || dbAction.base_action_id || undefined,
};
@@ -250,7 +257,9 @@ export function convertDatabaseToAction(dbAction: any): ExperimentAction {
const paramChildren = parameters.children;
if (Array.isArray(paramChildren)) {
children = paramChildren.map((child: any) => convertDatabaseToAction(child));
children = paramChildren.map((child: any) =>
convertDatabaseToAction(child),
);
}
return {

View File

@@ -156,7 +156,10 @@ export function collectPluginDependencies(design: ExperimentDesign): string[] {
return Array.from(set).sort();
}
// Helper to recursively collect from actions list directly would be cleaner
function collectDependenciesFromActions(actions: ExperimentAction[], set: Set<string>) {
function collectDependenciesFromActions(
actions: ExperimentAction[],
set: Set<string>,
) {
for (const action of actions) {
if (action.source.kind === "plugin" && action.source.pluginId) {
const versionPart = action.source.pluginVersion
@@ -208,7 +211,7 @@ function buildStructuralSignature(
timeout: a.timeout,
retryable: a.retryable ?? false,
parameterKeys: summarizeParametersForHash(a.parameters),
children: a.children?.map(c => ({
children: a.children?.map((c) => ({
id: c.id,
// Recurse structural signature for children
type: c.type,

View File

@@ -153,7 +153,8 @@ export function parseVisualDesignSteps(raw: unknown): {
issues.push(
...zodErr.issues.map(
(issue) =>
`steps${issue.path.length ? "." + issue.path.join(".") : ""
`steps${
issue.path.length ? "." + issue.path.join(".") : ""
}: ${issue.message} (code=${issue.code})`,
),
);
@@ -172,37 +173,37 @@ export function parseVisualDesignSteps(raw: unknown): {
robotId?: string | null;
baseActionId?: string;
} = a.source
? {
? {
kind: a.source.kind,
pluginId: a.source.pluginId,
pluginVersion: a.source.pluginVersion,
robotId: a.source.robotId ?? null,
baseActionId: a.source.baseActionId,
}
: { kind: "core" };
: { kind: "core" };
// Default execution
const execution: ExecutionDescriptor = a.execution
? {
transport: a.execution.transport,
timeoutMs: a.execution.timeoutMs,
retryable: a.execution.retryable,
ros2: a.execution.ros2,
rest: a.execution.rest
? {
method: a.execution.rest.method,
path: a.execution.rest.path,
headers: a.execution.rest.headers
? Object.fromEntries(
Object.entries(a.execution.rest.headers).filter(
(kv): kv is [string, string] =>
typeof kv[1] === "string",
),
)
: undefined,
}
: undefined,
}
transport: a.execution.transport,
timeoutMs: a.execution.timeoutMs,
retryable: a.execution.retryable,
ros2: a.execution.ros2,
rest: a.execution.rest
? {
method: a.execution.rest.method,
path: a.execution.rest.path,
headers: a.execution.rest.headers
? Object.fromEntries(
Object.entries(a.execution.rest.headers).filter(
(kv): kv is [string, string] =>
typeof kv[1] === "string",
),
)
: undefined,
}
: undefined,
}
: { transport: "internal" };
return {