Files
openmaic/OpenMAIC/packages/@openmaic/dsl/test/schema.test.ts
2026-08-16 14:58:47 +08:00

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}`);
}
}
});
}
});