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

360 lines
14 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
validateStage,
validateScene,
validateAction,
validateRuntimeSession,
validateRuntimeRecord,
type ValidationResult,
} from '@openmaic/dsl';
function errors(r: ValidationResult): string[] {
return r.valid ? [] : r.errors.map((e) => e.path);
}
describe('validateStage', () => {
it('accepts a well-formed stage', () => {
expect(validateStage({ id: 's', name: 'n', createdAt: 1, updatedAt: 2 })).toEqual({
valid: true,
});
});
it('collects every missing required field', () => {
const r = validateStage({ id: 's' });
expect(r.valid).toBe(false);
expect(errors(r)).toEqual(expect.arrayContaining(['/name', '/createdAt', '/updatedAt']));
});
it('rejects non-objects', () => {
expect(validateStage(null).valid).toBe(false);
expect(validateStage('x').valid).toBe(false);
});
});
describe('validateScene', () => {
const ok = {
id: 'sc1',
stageId: 'st1',
type: 'slide',
title: 'Intro',
order: 0,
content: { type: 'slide', canvas: { id: 'c' } },
};
it('accepts a well-formed slide scene', () => {
expect(validateScene(ok)).toEqual({ valid: true });
});
it('flags an unknown content type', () => {
const r = validateScene({ ...ok, content: { type: 'bogus' } });
expect(errors(r)).toContain('/content/type');
});
it('flags a quiz scene missing its questions array', () => {
const r = validateScene({ ...ok, type: 'quiz', content: { type: 'quiz' } });
expect(errors(r)).toContain('/content/questions');
});
it('flags app-widened scene kinds (contract owns only slide/quiz)', () => {
const r = validateScene({ ...ok, type: 'pbl', content: { type: 'pbl' } });
expect(errors(r)).toContain('/type');
});
it('flags a scene whose content.type disagrees with its type', () => {
const r = validateScene({
...ok,
type: 'quiz',
content: { type: 'slide', canvas: { id: 'c' } },
});
expect(r.valid).toBe(false);
expect(errors(r)).toContain('/content/type');
});
it('validates nested actions and points at the bad one', () => {
const r = validateScene({
...ok,
actions: [
{ id: 'a', type: 'speech', text: 'hi' },
{ id: 'b', type: 'nope' },
],
});
expect(errors(r)).toContain('/actions/1/type');
});
});
describe('validateAction', () => {
it('accepts a well-formed action (variant fields present)', () => {
expect(validateAction({ id: 'a', type: 'spotlight', elementId: 'e' })).toEqual({ valid: true });
});
it('rejects an unknown action type', () => {
const r = validateAction({ id: 'a', type: 'frobnicate' });
expect(errors(r)).toContain('/type');
});
it('flags a known action type missing a variant-required field', () => {
// A spotlight with no elementId is unusable at runtime.
const r = validateAction({ id: 'a', type: 'spotlight' });
expect(errors(r)).toContain('/elementId');
const d = validateAction({ id: 'a', type: 'discussion' });
expect(errors(d)).toContain('/topic');
});
it('flags a variant-required field of the wrong type', () => {
// present but mis-typed: elementId must be a string, not a number.
const r = validateAction({ id: 'a', type: 'spotlight', elementId: 123 });
expect(errors(r)).toContain('/elementId');
});
it('requires a string id', () => {
const r = validateAction({ type: 'laser', elementId: 'e' });
expect(errors(r)).toContain('/id');
});
});
describe('validateRuntimeSession', () => {
const good = {
id: 's1',
kind: 'chat',
stageId: 'stage1',
learnerKey: 'anon:device-1',
status: 'active',
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
// Required: sessions are born stamped, the runtime line has no unversioned epoch.
runtimeDslVersion: '0.1.0',
};
it('accepts a minimal valid session', () => {
expect(validateRuntimeSession(good)).toEqual({ valid: true });
});
it('accepts an app-defined kind alongside the required runtimeDslVersion', () => {
expect(
validateRuntimeSession({ ...good, kind: 'myWidget', runtimeDslVersion: '0.1.0' }),
).toEqual({
valid: true,
});
});
it('rejects a session missing runtimeDslVersion, reported once at /runtimeDslVersion', () => {
// The stamp is required now: an absent one is a bug (a misrouted legacy
// document or an unstamped producer write), not legacy data to lift, since
// the runtime line has no unversioned epoch. Reported exactly once.
const { runtimeDslVersion: _v, ...noStamp } = good;
const result = validateRuntimeSession(noStamp);
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
const stampErrors = result.errors.filter((e) => e.path === '/runtimeDslVersion');
expect(stampErrors).toHaveLength(1);
expect(stampErrors[0].message).toMatch(/missing/);
});
it('rejects non-objects', () => {
const result = validateRuntimeSession(null);
expect(result.valid).toBe(false);
});
it('reports every missing required field with a path', () => {
const result = validateRuntimeSession({});
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
const paths = result.errors.map((e) => e.path);
for (const p of [
'/id',
'/kind',
'/stageId',
'/learnerKey',
'/status',
'/createdAt',
'/updatedAt',
// Required now: an empty session is missing the runtime stamp too.
'/runtimeDslVersion',
]) {
expect(paths).toContain(p);
}
});
it('rejects an unknown status value', () => {
const result = validateRuntimeSession({ ...good, status: 'paused' });
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
expect(result.errors[0].path).toBe('/status');
});
it('rejects a non-string runtimeDslVersion', () => {
const result = validateRuntimeSession({ ...good, runtimeDslVersion: 1 });
expect(result.valid).toBe(false);
});
it('rejects a present-but-malformed runtimeDslVersion stamp', () => {
// A well-formed string that is not `x.y.z` would pass the typeof check yet
// make `migrateRuntime`/`runtimeDslVersionOf` throw downstream — reject it
// at the door.
const result = validateRuntimeSession({ ...good, runtimeDslVersion: 'legacy' });
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
expect(result.errors.map((e) => e.path)).toContain('/runtimeDslVersion');
});
it('accepts a well-formed runtimeDslVersion stamp', () => {
expect(validateRuntimeSession({ ...good, runtimeDslVersion: '0.1.0' })).toEqual({
valid: true,
});
});
it('rejects a stray document-line dslVersion on a session', () => {
// A session versions on `runtimeDslVersion`; `dslVersion` never belongs on
// its shape. This is the deliberate exception to the structural-subset rule:
// a stray sibling stamp is evidence of a misrouted migration, and once
// stored it makes the envelope ambiguous to the cross-line guard (which
// fails loud on own-stamp-absent + sibling-stamp-present) — so reject it at
// the door, whatever its value.
for (const stray of ['legacy', '0.1.0']) {
const result = validateRuntimeSession({ ...good, dslVersion: stray });
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
expect(result.errors.map((e) => e.path)).toContain('/dslVersion');
}
// Rejected even alongside a well-formed own-line stamp: a doubly-stamped
// session is equally the product of a misroute.
const doubly = validateRuntimeSession({
...good,
runtimeDslVersion: '0.1.0',
dslVersion: '0.1.0',
});
expect(doubly.valid).toBe(false);
});
it('reports an empty-string createdAt exactly once, at /createdAt', () => {
// The required-field table already flags an empty string as non-empty; the
// ISO refinement must not fire a second error at the same path.
const result = validateRuntimeSession({ ...good, createdAt: '' });
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
const createdAtErrors = result.errors.filter((e) => e.path === '/createdAt');
expect(createdAtErrors).toHaveLength(1);
});
it('rejects an empty required string (learnerKey)', () => {
// `''` passes typeof but is useless as a partition key.
const result = validateRuntimeSession({ ...good, learnerKey: '' });
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
expect(result.errors.map((e) => e.path)).toContain('/learnerKey');
});
it('rejects a non-ISO-8601 createdAt / updatedAt (contract says ISO 8601)', () => {
// These pass the `typeof === 'string'` table check but are not timestamps;
// the contract docs call them ISO 8601, so a bare string is not enough.
const bad = validateRuntimeSession({
...good,
createdAt: 'not-a-date',
updatedAt: 'still-bad',
});
expect(bad.valid).toBe(false);
if (bad.valid) throw new Error('unreachable');
const paths = bad.errors.map((e) => e.path);
expect(paths).toContain('/createdAt');
expect(paths).toContain('/updatedAt');
});
it('rejects a calendar-impossible ISO date (month 13)', () => {
// Well-formed shape but not a real date: the regex alone would accept it,
// so `Date.parse` calendar validity is what rejects it.
const r = validateRuntimeSession({ ...good, createdAt: '2026-13-01T00:00:00.000Z' });
expect(r.valid).toBe(false);
if (r.valid) throw new Error('unreachable');
expect(r.errors.map((e) => e.path)).toContain('/createdAt');
});
it('accepts an ISO-8601 offset timestamp form', () => {
expect(validateRuntimeSession({ ...good, createdAt: '2026-01-01T08:00:00+08:00' })).toEqual({
valid: true,
});
});
});
describe('validateRuntimeRecord', () => {
const good = {
id: 'r1',
sessionId: 's1',
seq: 0,
createdAt: '2026-01-01T00:00:01.000Z',
payload: { role: 'user', content: 'hi' },
};
it('accepts a minimal valid record (payload is opaque)', () => {
expect(validateRuntimeRecord(good)).toEqual({ valid: true });
});
it('accepts optional anchors of the right shape', () => {
expect(
validateRuntimeRecord({ ...good, sceneId: 'sc1', actionIndex: 2, subAnchor: 'q3' }),
).toEqual({ valid: true });
});
it('rejects a record with no payload key', () => {
const { payload: _payload, ...rest } = good;
const result = validateRuntimeRecord(rest);
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
expect(result.errors.map((e) => e.path)).toContain('/payload');
});
it('rejects an explicit `payload: undefined` but accepts a null payload', () => {
// `'payload' in doc` would pass an explicit-undefined; require a real value.
// `null` stays legal — it is a value the app may have deliberately stored.
const undef = validateRuntimeRecord({ ...good, payload: undefined });
expect(undef.valid).toBe(false);
if (undef.valid) throw new Error('unreachable');
expect(undef.errors.map((e) => e.path)).toContain('/payload');
expect(validateRuntimeRecord({ ...good, payload: null })).toEqual({ valid: true });
});
it('rejects a seq that is not a non-negative integer', () => {
// `seq` is the sole replay ordering key; NaN/Infinity/negative/fractional
// all pass `typeof === "number"` but would corrupt ordering.
for (const seq of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) {
const r = validateRuntimeRecord({ ...good, seq });
expect(r.valid).toBe(false);
if (r.valid) throw new Error('unreachable');
expect(r.errors.map((e) => e.path)).toContain('/seq');
}
expect(validateRuntimeRecord({ ...good, seq: 0 })).toEqual({ valid: true });
});
it('rejects a negative optional actionIndex', () => {
const r = validateRuntimeRecord({ ...good, actionIndex: -1 });
expect(r.valid).toBe(false);
if (r.valid) throw new Error('unreachable');
expect(r.errors.map((e) => e.path)).toContain('/actionIndex');
});
it('rejects an empty required string (id)', () => {
const r = validateRuntimeRecord({ ...good, id: '' });
expect(r.valid).toBe(false);
if (r.valid) throw new Error('unreachable');
expect(r.errors.map((e) => e.path)).toContain('/id');
});
it('rejects wrongly-typed optional anchors', () => {
expect(validateRuntimeRecord({ ...good, actionIndex: 'x' }).valid).toBe(false);
expect(validateRuntimeRecord({ ...good, sceneId: 5 }).valid).toBe(false);
expect(validateRuntimeRecord({ ...good, subAnchor: 5 }).valid).toBe(false);
});
it('rejects missing required fields with paths', () => {
const result = validateRuntimeRecord({});
expect(result.valid).toBe(false);
if (result.valid) throw new Error('unreachable');
const paths = result.errors.map((e) => e.path);
for (const p of ['/id', '/sessionId', '/seq', '/createdAt', '/payload']) {
expect(paths).toContain(p);
}
});
it('rejects a non-ISO-8601 createdAt (contract says ISO 8601)', () => {
const r = validateRuntimeRecord({ ...good, createdAt: 'not-a-date' });
expect(r.valid).toBe(false);
if (r.valid) throw new Error('unreachable');
expect(r.errors.map((e) => e.path)).toContain('/createdAt');
// A calendar-impossible but well-formed date is rejected too (Date.parse).
expect(validateRuntimeRecord({ ...good, createdAt: '2026-13-01T00:00:00.000Z' }).valid).toBe(
false,
);
// An offset form is accepted.
expect(validateRuntimeRecord({ ...good, createdAt: '2026-01-01T08:00:00+08:00' })).toEqual({
valid: true,
});
});
});