Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

134 lines
4.6 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;
function validator(root: keyof typeof schemas) {
return new Ajv({ allErrors: true, strict: false }).compile(schemas[root]);
}
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);
});
});
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: [],
},
},
};
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('rejects content that is not a contract content kind (slide/quiz)', () => {
expect(v({ ...slideScene, content: { type: 'pbl' } })).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}`);
}
}
});
}
});