mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat: Implement digital signatures for participant consent and introduce study forms management.
This commit is contained in:
@@ -37,11 +37,12 @@ export function isWizard(session: Session | null): boolean {
|
||||
/**
|
||||
* Check if the current user has any of the specified roles
|
||||
*/
|
||||
export function hasAnyRole(session: Session | null, roles: SystemRole[]): boolean {
|
||||
export function hasAnyRole(
|
||||
session: Session | null,
|
||||
roles: SystemRole[],
|
||||
): boolean {
|
||||
if (!session?.user?.roles) return false;
|
||||
return session.user.roles.some((userRole) =>
|
||||
roles.includes(userRole.role)
|
||||
);
|
||||
return session.user.roles.some((userRole) => roles.includes(userRole.role));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +50,7 @@ export function hasAnyRole(session: Session | null, roles: SystemRole[]): boolea
|
||||
*/
|
||||
export function canAccessResource(
|
||||
session: Session | null,
|
||||
resourceOwnerId: string
|
||||
resourceOwnerId: string,
|
||||
): boolean {
|
||||
if (!session?.user) return false;
|
||||
|
||||
@@ -98,7 +99,12 @@ export function getAvailableRoles(): Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
}> {
|
||||
const roles: SystemRole[] = ["administrator", "researcher", "wizard", "observer"];
|
||||
const roles: SystemRole[] = [
|
||||
"administrator",
|
||||
"researcher",
|
||||
"wizard",
|
||||
"observer",
|
||||
];
|
||||
|
||||
return roles.map((role) => ({
|
||||
value: role,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -70,7 +70,9 @@ export function getCameraImage(
|
||||
): Record<string, unknown> {
|
||||
const camera = params.camera as string;
|
||||
const topic =
|
||||
camera === "front" ? "/naoqi_driver/camera/front/image_raw" : "/naoqi_driver/camera/bottom/image_raw";
|
||||
camera === "front"
|
||||
? "/naoqi_driver/camera/front/image_raw"
|
||||
: "/naoqi_driver/camera/bottom/image_raw";
|
||||
|
||||
return {
|
||||
subscribe: true,
|
||||
@@ -129,7 +131,10 @@ export function getTouchSensors(
|
||||
params: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const sensorType = params.sensor_type as string;
|
||||
const topic = sensorType === "hand" ? "/naoqi_driver/hand_touch" : "/naoqi_driver/head_touch";
|
||||
const topic =
|
||||
sensorType === "hand"
|
||||
? "/naoqi_driver/hand_touch"
|
||||
: "/naoqi_driver/head_touch";
|
||||
const messageType =
|
||||
sensorType === "hand"
|
||||
? "naoqi_bridge_msgs/msg/HandTouch"
|
||||
|
||||
61
src/lib/pdf-generator.ts
Normal file
61
src/lib/pdf-generator.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface PdfOptions {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
const getHtml2PdfOptions = (filename?: string) => ({
|
||||
margin: 0.5,
|
||||
filename: filename ?? 'document.pdf',
|
||||
image: { type: 'jpeg' as const, quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true, backgroundColor: "#ffffff", windowWidth: 800 },
|
||||
jsPDF: { unit: 'in', format: 'letter' as const, orientation: 'portrait' as const }
|
||||
});
|
||||
|
||||
const createPrintWrapper = (htmlContent: string) => {
|
||||
const printWrapper = document.createElement("div");
|
||||
printWrapper.style.position = "absolute";
|
||||
printWrapper.style.left = "-9999px";
|
||||
printWrapper.style.top = "0px";
|
||||
printWrapper.className = "light"; // Prevent dark mode variables from bleeding into the physical PDF
|
||||
|
||||
const element = document.createElement("div");
|
||||
element.innerHTML = htmlContent;
|
||||
// Assign standard prose layout and explicitly white/black print colors
|
||||
element.className = "prose prose-sm max-w-none p-12 bg-white text-black";
|
||||
element.style.width = "800px";
|
||||
element.style.backgroundColor = "white";
|
||||
element.style.color = "black";
|
||||
|
||||
printWrapper.appendChild(element);
|
||||
document.body.appendChild(printWrapper);
|
||||
|
||||
return { printWrapper, element };
|
||||
};
|
||||
|
||||
export async function downloadPdfFromHtml(htmlContent: string, options: PdfOptions = {}): Promise<void> {
|
||||
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
|
||||
const html2pdf = (await import('html2pdf.js')).default;
|
||||
|
||||
const { printWrapper, element } = createPrintWrapper(htmlContent);
|
||||
|
||||
try {
|
||||
const opt = getHtml2PdfOptions(options.filename);
|
||||
await html2pdf().set(opt).from(element).save();
|
||||
} finally {
|
||||
document.body.removeChild(printWrapper);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generatePdfBlobFromHtml(htmlContent: string, options: PdfOptions = {}): Promise<Blob> {
|
||||
// @ts-ignore - Dynamic import to prevent SSR issues with window/document
|
||||
const html2pdf = (await import('html2pdf.js')).default;
|
||||
|
||||
const { printWrapper, element } = createPrintWrapper(htmlContent);
|
||||
|
||||
try {
|
||||
const opt = getHtml2PdfOptions(options.filename);
|
||||
const pdfBlob = await html2pdf().set(opt).from(element).output('blob');
|
||||
return pdfBlob;
|
||||
} finally {
|
||||
document.body.removeChild(printWrapper);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,10 @@ export class WizardRosService extends EventEmitter {
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.warn("[WizardROS] WebSocket error (connection may be retried):", error);
|
||||
console.warn(
|
||||
"[WizardROS] WebSocket error (connection may be retried):",
|
||||
error,
|
||||
);
|
||||
clearTimeout(connectionTimeout);
|
||||
this.isConnecting = false;
|
||||
|
||||
@@ -518,7 +521,10 @@ export class WizardRosService extends EventEmitter {
|
||||
break;
|
||||
}
|
||||
|
||||
this.publish("/naoqi_driver/cmd_vel", "geometry_msgs/Twist", { linear, angular });
|
||||
this.publish("/naoqi_driver/cmd_vel", "geometry_msgs/Twist", {
|
||||
linear,
|
||||
angular,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -549,11 +555,15 @@ export class WizardRosService extends EventEmitter {
|
||||
const jointNames = [`${prefix}ShoulderPitch`, `${prefix}ShoulderRoll`];
|
||||
const jointAngles = [pitch, roll];
|
||||
|
||||
this.publish("/naoqi_driver/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
|
||||
joint_names: jointNames,
|
||||
joint_angles: jointAngles,
|
||||
speed: speed,
|
||||
});
|
||||
this.publish(
|
||||
"/naoqi_driver/joint_angles",
|
||||
"naoqi_bridge_msgs/JointAnglesWithSpeed",
|
||||
{
|
||||
joint_names: jointNames,
|
||||
joint_angles: jointAngles,
|
||||
speed: speed,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -574,7 +584,10 @@ export class WizardRosService extends EventEmitter {
|
||||
if (message.op === "service_response" && message.id === id) {
|
||||
this.off("message", handleResponse);
|
||||
if (message.result === false) {
|
||||
resolve({ result: false, error: String(message.values || "Service call failed") });
|
||||
resolve({
|
||||
result: false,
|
||||
error: String(message.values || "Service call failed"),
|
||||
});
|
||||
} else {
|
||||
resolve({ result: true, values: message.values });
|
||||
}
|
||||
@@ -608,30 +621,30 @@ export class WizardRosService extends EventEmitter {
|
||||
// Standard NaoQi Bridge pattern
|
||||
{
|
||||
service: "/naoqi_driver/ALAutonomousLife/setState",
|
||||
args: { state: desiredState }
|
||||
args: { state: desiredState },
|
||||
},
|
||||
{
|
||||
service: "/naoqi_driver/ALAutonomousLife/set_state",
|
||||
args: { state: desiredState }
|
||||
args: { state: desiredState },
|
||||
},
|
||||
// Direct module mapping
|
||||
{
|
||||
service: "/ALAutonomousLife/setState",
|
||||
args: { state: desiredState }
|
||||
args: { state: desiredState },
|
||||
},
|
||||
// Shortcuts/Aliases
|
||||
{
|
||||
service: "/naoqi_driver/set_autonomous_life",
|
||||
args: { state: desiredState }
|
||||
args: { state: desiredState },
|
||||
},
|
||||
{
|
||||
service: "/autonomous_life/set_state",
|
||||
args: { state: desiredState }
|
||||
args: { state: desiredState },
|
||||
},
|
||||
// Fallback: Enable/Disable topics/services
|
||||
{
|
||||
service: enabled ? "/life/enable" : "/life/disable",
|
||||
args: {}
|
||||
args: {},
|
||||
},
|
||||
// Last resort: Generic proxy call (if available)
|
||||
{
|
||||
@@ -639,9 +652,9 @@ export class WizardRosService extends EventEmitter {
|
||||
args: {
|
||||
service: "ALAutonomousLife",
|
||||
function: "setState",
|
||||
args: [desiredState]
|
||||
}
|
||||
}
|
||||
args: [desiredState],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
console.log(`[WizardROS] Setting Autonomous Life to: ${desiredState}`);
|
||||
@@ -657,11 +670,17 @@ export class WizardRosService extends EventEmitter {
|
||||
return true;
|
||||
} else {
|
||||
// Resolved but failed? (e.g. internal error)
|
||||
console.warn(`[WizardROS] Service ${attempt.service} returned false result:`, response.error);
|
||||
console.warn(
|
||||
`[WizardROS] Service ${attempt.service} returned false result:`,
|
||||
response.error,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Service call failed or timed out
|
||||
console.warn(`[WizardROS] Service ${attempt.service} failed/timeout:`, error);
|
||||
console.warn(
|
||||
`[WizardROS] Service ${attempt.service} failed/timeout:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2) {
|
||||
@@ -10,7 +10,17 @@ export function formatBytes(bytes: number, decimals = 2) {
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ["Bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"];
|
||||
const sizes = [
|
||||
"Bytes",
|
||||
"KiB",
|
||||
"MiB",
|
||||
"GiB",
|
||||
"TiB",
|
||||
"PiB",
|
||||
"EiB",
|
||||
"ZiB",
|
||||
"YiB",
|
||||
];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user