345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import Ajv from 'ajv';
|
|
import { validateAction } from '@openmaic/dsl';
|
|
// JS codegen helper (the build-only generator); vitest/esbuild resolves it at
|
|
// runtime and tsc infers its exports via allowJs.
|
|
import { generateSchema } from '../scripts/gen-schema.mjs';
|
|
|
|
// Generate every root schema once (the generator parses the TS program a single
|
|
// time and is reused), then compile per case — no repeated heavy codegen.
|
|
const schemas = {
|
|
Stage: generateSchema('Stage'),
|
|
Action: generateSchema('Action'),
|
|
SerializedScene: generateSchema('SerializedScene'),
|
|
} as const;
|
|
|
|
type GeneratedSchema = {
|
|
definitions: Record<
|
|
string,
|
|
{
|
|
type?: string;
|
|
properties?: Record<string, Record<string, unknown>>;
|
|
additionalProperties?: unknown;
|
|
}
|
|
>;
|
|
};
|
|
|
|
function validator(root: keyof typeof schemas) {
|
|
return new Ajv({ allErrors: true, strict: false }).compile(schemas[root]);
|
|
}
|
|
|
|
describe('generated JSON Schema — asset references', () => {
|
|
it.each(Object.entries(schemas))('%s defines AssetRef as a string', (_root, schema) => {
|
|
expect((schema as GeneratedSchema).definitions.AssetRef).toMatchObject({ type: 'string' });
|
|
});
|
|
|
|
it.each(Object.entries(schemas))('%s emits only boolean deprecated keywords', (root, schema) => {
|
|
for (const [definitionName, definition] of Object.entries(
|
|
(schema as GeneratedSchema).definitions,
|
|
)) {
|
|
for (const [propertyName, property] of Object.entries(definition.properties ?? {})) {
|
|
if (!Object.prototype.hasOwnProperty.call(property, 'deprecated')) continue;
|
|
const path = `${root}.definitions.${definitionName}.properties.${propertyName}.deprecated`;
|
|
expect(typeof property.deprecated, `${path} = ${JSON.stringify(property.deprecated)}`).toBe(
|
|
'boolean',
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('generated JSON Schema — Stage', () => {
|
|
const v = validator('Stage');
|
|
it('accepts a well-formed stage', () => {
|
|
expect(v({ id: 's', name: 'n', createdAt: 1, updatedAt: 2 })).toBe(true);
|
|
});
|
|
it('rejects a stage missing required fields', () => {
|
|
expect(v({ id: 's' })).toBe(false);
|
|
});
|
|
it('accepts a roster with and without the optional voice fields', () => {
|
|
const agent = {
|
|
id: 'gen-1',
|
|
name: 'Narrator',
|
|
role: 'teacher',
|
|
persona: 'p',
|
|
avatar: 'a',
|
|
color: '#000',
|
|
priority: 10,
|
|
};
|
|
const voiced = {
|
|
...agent,
|
|
id: 'gen-2',
|
|
voiceConfig: { providerId: 'tts', voiceId: 'v1' },
|
|
voiceDesign: { identity: 'adult narrator', texture: 'low', delivery: 'calm' },
|
|
};
|
|
expect(
|
|
v({
|
|
id: 's',
|
|
name: 'n',
|
|
createdAt: 1,
|
|
updatedAt: 2,
|
|
generatedAgentConfigs: [agent, voiced],
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('generated JSON Schema — Action', () => {
|
|
const v = validator('Action');
|
|
it('accepts a spotlight action', () => {
|
|
expect(v({ id: 'a', type: 'spotlight', elementId: 'e' })).toBe(true);
|
|
});
|
|
it('rejects an action missing its required discriminated fields', () => {
|
|
expect(v({ id: 'a', type: 'spotlight' /* missing elementId */ })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('generated JSON Schema — SerializedScene', () => {
|
|
const v = validator('SerializedScene');
|
|
const slideScene = {
|
|
id: 'sc',
|
|
stageId: 'st',
|
|
type: 'slide',
|
|
title: 't',
|
|
order: 0,
|
|
content: {
|
|
type: 'slide',
|
|
canvas: {
|
|
id: 'c',
|
|
viewportSize: 1920,
|
|
viewportRatio: 0.5625,
|
|
theme: { themeColors: [], fontColor: '#000', fontName: 'Arial', backgroundColor: '#fff' },
|
|
elements: [],
|
|
},
|
|
},
|
|
};
|
|
const legacyPBLScene = {
|
|
id: 'legacy-pbl',
|
|
stageId: 'st',
|
|
type: 'pbl',
|
|
title: 'Legacy project',
|
|
order: 1,
|
|
content: {
|
|
type: 'pbl',
|
|
projectConfig: {
|
|
projectInfo: {
|
|
title: 'Neighborhood garden',
|
|
description: 'Plan a shared garden for the neighborhood.',
|
|
},
|
|
agents: [{ id: 'mentor-1', name: 'Mentor', role: 'mentor' }],
|
|
issueboard: {
|
|
issues: [{ id: 'issue-1', title: 'Choose the site', description: 'Compare options.' }],
|
|
},
|
|
chat: {
|
|
messages: [{ id: 'message-1', role: 'assistant', content: 'Let us compare sites.' }],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const plannerPBLScene = {
|
|
id: 'planner-pbl',
|
|
stageId: 'st',
|
|
type: 'pbl',
|
|
title: 'Planner project',
|
|
order: 2,
|
|
content: {
|
|
type: 'pbl',
|
|
projectV2: {
|
|
uiPhase: 'hero',
|
|
title: 'Build a weather station',
|
|
description: 'Create and explain a simple weather station.',
|
|
learningObjective: 'Interpret sensor readings and communicate uncertainty.',
|
|
gains: ['Read sensors', 'Explain observations'],
|
|
tags: ['science', 'sensors'],
|
|
language: 'en-US',
|
|
proficiency: 'beginner',
|
|
status: 'active',
|
|
roles: [
|
|
{
|
|
id: 'instructor',
|
|
type: 'instructor',
|
|
name: 'Instructor',
|
|
description: 'Guides the project.',
|
|
},
|
|
],
|
|
milestones: [
|
|
{
|
|
id: 'milestone-1',
|
|
title: 'Assemble the station',
|
|
status: 'active',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'task-1',
|
|
title: 'Connect the sensor',
|
|
status: 'todo',
|
|
assignee: 'user',
|
|
hints: ['Check the pin labels.'],
|
|
order: 0,
|
|
},
|
|
],
|
|
documents: [
|
|
{
|
|
id: 'doc-1',
|
|
title: 'Wiring guide',
|
|
content: '# Sensor wiring',
|
|
docType: 'markdown',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
submissions: [],
|
|
evaluations: [],
|
|
threads: [{ agentId: 'instructor', messages: [] }],
|
|
engagementEvents: [],
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
},
|
|
};
|
|
const interactiveScene = {
|
|
id: 'interactive-scene',
|
|
stageId: 'st',
|
|
type: 'interactive',
|
|
title: 'Simulation',
|
|
order: 3,
|
|
content: {
|
|
type: 'interactive',
|
|
html: '<!doctype html><div id="simulation"></div>',
|
|
widgetType: 'simulation',
|
|
widgetConfig: {
|
|
type: 'simulation',
|
|
concept: 'projectile motion',
|
|
variables: [{ name: 'velocity', initialValue: 12 }],
|
|
controls: { reset: true, pause: true },
|
|
},
|
|
},
|
|
};
|
|
it('accepts a well-formed slide scene', () => {
|
|
expect(v(slideScene)).toBe(true);
|
|
});
|
|
it('rejects a scene missing required fields', () => {
|
|
expect(v({ id: 'sc' })).toBe(false);
|
|
});
|
|
it('accepts a realistic legacy PBL scene with opaque v1 projectConfig', () => {
|
|
expect(v(legacyPBLScene), JSON.stringify(v.errors)).toBe(true);
|
|
});
|
|
it('accepts a planner-produced v2 project with the full seeded skeleton', () => {
|
|
expect(v(plannerPBLScene), JSON.stringify(v.errors)).toBe(true);
|
|
});
|
|
it('accepts realistic app-owned widget config fields but rejects an unknown widget type', () => {
|
|
expect(v(interactiveScene), JSON.stringify(v.errors)).toBe(true);
|
|
expect(
|
|
v({
|
|
...interactiveScene,
|
|
content: {
|
|
...interactiveScene.content,
|
|
widgetConfig: { ...interactiveScene.content.widgetConfig, type: 'bogus' },
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
const { type: _type, ...configWithoutType } = interactiveScene.content.widgetConfig;
|
|
expect(
|
|
v({
|
|
...interactiveScene,
|
|
content: { ...interactiveScene.content, widgetConfig: configWithoutType },
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
it('keeps the widget config and historical PBL runtime-overlay surfaces open', () => {
|
|
const definitions = (schemas.SerializedScene as GeneratedSchema).definitions;
|
|
expect(definitions.WidgetConfigBase.additionalProperties).not.toBe(false);
|
|
for (const name of ['PBLProject', 'PBLMilestone', 'PBLMicrotask', 'PBLThreadSeat']) {
|
|
expect(definitions[name].additionalProperties, name).not.toBe(false);
|
|
}
|
|
|
|
const project = plannerPBLScene.content.projectV2;
|
|
const sceneWithRuntimeOverlay = {
|
|
...plannerPBLScene,
|
|
content: {
|
|
...plannerPBLScene.content,
|
|
projectV2: {
|
|
...project,
|
|
runtimeEvents: [],
|
|
milestones: project.milestones.map((milestone) => ({
|
|
...milestone,
|
|
microtasks: milestone.microtasks.map((microtask) => ({
|
|
...microtask,
|
|
engagement: { learnerTurnCount: 2 },
|
|
})),
|
|
})),
|
|
},
|
|
},
|
|
};
|
|
expect(v(sceneWithRuntimeOverlay), JSON.stringify(v.errors)).toBe(true);
|
|
});
|
|
it('rejects a cross-kind slide/PBL content mismatch', () => {
|
|
expect(v({ ...slideScene, content: legacyPBLScene.content })).toBe(false);
|
|
});
|
|
it('rejects a scene whose type disagrees with its content (type<->content bound)', () => {
|
|
expect(v({ ...slideScene, type: 'quiz' })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('validateAction variant fields stay in lockstep with the Action schema', () => {
|
|
// The schema is generated from the TS types, so it is the source of truth for
|
|
// each variant's required fields and their types. These checks pin the
|
|
// hand-written required-field map in validate.ts to it BOTH ways: it cannot
|
|
// under-list (a schema-required field validateAction ignores), over-list (an
|
|
// optional field validateAction wrongly demands), or mis-type a field.
|
|
const schema = schemas.Action as {
|
|
definitions: Record<
|
|
string,
|
|
{
|
|
anyOf?: { $ref: string }[];
|
|
properties?: Record<string, { const?: string; type?: string }>;
|
|
required?: string[];
|
|
}
|
|
>;
|
|
};
|
|
const GOOD: Record<string, unknown> = {
|
|
string: 's',
|
|
number: 1,
|
|
boolean: true,
|
|
object: {},
|
|
array: [],
|
|
};
|
|
const BAD: Record<string, unknown> = {
|
|
string: 1,
|
|
number: 's',
|
|
boolean: 's',
|
|
object: 's',
|
|
array: {},
|
|
};
|
|
const paths = (doc: unknown) => {
|
|
const r = validateAction(doc);
|
|
return r.valid ? [] : r.errors.map((e) => e.path);
|
|
};
|
|
|
|
const branches = schema.definitions.Action.anyOf ?? [];
|
|
for (const branch of branches) {
|
|
const def = schema.definitions[branch.$ref.split('/').pop() as string];
|
|
const type = def.properties?.type?.const;
|
|
if (!type) continue;
|
|
const required = (def.required ?? []).filter((f) => f !== 'id' && f !== 'type');
|
|
const kinds = Object.fromEntries(required.map((f) => [f, def.properties?.[f]?.type]));
|
|
|
|
it(`validateAction accepts a fully-typed ${type} (no over-listing)`, () => {
|
|
const good = Object.fromEntries(required.map((f) => [f, GOOD[kinds[f] ?? 'string']]));
|
|
expect(validateAction({ id: 'a', type, ...good })).toEqual({ valid: true });
|
|
});
|
|
|
|
it(`validateAction flags ${type}'s missing/mis-typed required fields [${required.join(', ')}]`, () => {
|
|
for (const f of required) {
|
|
// missing → flagged
|
|
expect(paths({ id: 'a', type })).toContain(`/${f}`);
|
|
// present but wrong-typed → flagged
|
|
const st = kinds[f];
|
|
if (typeof st === 'string' && st in BAD) {
|
|
expect(paths({ id: 'a', type, [f]: BAD[st] })).toContain(`/${f}`);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|