mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat(analytics): refine timeline visualization and add print support
This commit is contained in:
110
src/lib/experiment-designer/__tests__/control-flow.test.ts
Normal file
110
src/lib/experiment-designer/__tests__/control-flow.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
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" }
|
||||
};
|
||||
|
||||
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
|
||||
},
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
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);
|
||||
});
|
||||
});
|
||||
106
src/lib/experiment-designer/__tests__/hashing.test.ts
Normal file
106
src/lib/experiment-designer/__tests__/hashing.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Hashing } from "../../../components/experiments/designer/state/hashing";
|
||||
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("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",
|
||||
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",
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
138
src/lib/experiment-designer/__tests__/store.test.ts
Normal file
138
src/lib/experiment-designer/__tests__/store.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { createDesignerStore } from "../../../components/experiments/designer/state/store";
|
||||
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: []
|
||||
});
|
||||
};
|
||||
|
||||
describe("Designer Store Integration", () => {
|
||||
let store: ReturnType<typeof createTestStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createTestStore();
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
store.getState().upsertStep(step1);
|
||||
store.getState().upsertStep(step2);
|
||||
|
||||
// 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");
|
||||
|
||||
// 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);
|
||||
|
||||
const action: ExperimentAction = {
|
||||
id: "act-1",
|
||||
type: "log",
|
||||
name: "Log",
|
||||
parameters: {},
|
||||
source: { kind: "core", baseActionId: "log" },
|
||||
execution: { transport: "internal" }
|
||||
};
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
122
src/lib/experiment-designer/__tests__/validators.test.ts
Normal file
122
src/lib/experiment-designer/__tests__/validators.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
|
||||
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";
|
||||
|
||||
// Mock Data
|
||||
const mockActionDef: ActionDefinition = {
|
||||
id: "core.log",
|
||||
name: "Log Info",
|
||||
type: "log",
|
||||
category: "utility",
|
||||
parameters: [
|
||||
{ id: "message", name: "Message", type: "text", required: true },
|
||||
{ id: "level", name: "Level", type: "select", options: ["info", "warn", "error"], default: "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"
|
||||
};
|
||||
|
||||
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("Parameter Validation", () => {
|
||||
it("should fail if required parameter is missing", () => {
|
||||
const action: ExperimentAction = {
|
||||
id: "act-1",
|
||||
type: "log",
|
||||
name: "Log",
|
||||
order: 0,
|
||||
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",
|
||||
order: 0,
|
||||
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",
|
||||
order: 0,
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -138,12 +138,21 @@ export function convertActionToDatabase(
|
||||
action: ExperimentAction,
|
||||
orderIndex: number,
|
||||
): ConvertedAction {
|
||||
// Serialize nested children into parameters for storage
|
||||
const parameters = { ...action.parameters };
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
return {
|
||||
name: action.name,
|
||||
description: `${action.type} action`,
|
||||
type: action.type,
|
||||
orderIndex,
|
||||
parameters: action.parameters,
|
||||
parameters,
|
||||
timeout: estimateActionTimeout(action),
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
@@ -231,15 +240,29 @@ export function convertDatabaseToAction(dbAction: any): ExperimentAction {
|
||||
retryable: dbAction.retryable ?? false,
|
||||
};
|
||||
|
||||
// Convert definitions to runtime action, handling nested children
|
||||
const parameters = (dbAction.parameters as Record<string, unknown>) || {};
|
||||
|
||||
// Hydrate nested children (Sequence, Parallel, Loop only)
|
||||
// Branch actions control step routing, not nested actions
|
||||
let children: ExperimentAction[] | undefined = undefined;
|
||||
|
||||
const paramChildren = parameters.children;
|
||||
|
||||
if (Array.isArray(paramChildren)) {
|
||||
children = paramChildren.map((child: any) => convertDatabaseToAction(child));
|
||||
}
|
||||
|
||||
return {
|
||||
id: dbAction.id,
|
||||
name: dbAction.name,
|
||||
description: dbAction.description ?? undefined,
|
||||
type: dbAction.type,
|
||||
category: dbAction.category ?? "general",
|
||||
parameters: (dbAction.parameters as Record<string, unknown>) || {},
|
||||
parameters,
|
||||
source,
|
||||
execution,
|
||||
parameterSchemaRaw: dbAction.parameterSchemaRaw,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user