Files
openmaic/OpenMAIC/tests/lib/whiteboard/runtime-contract.test.ts
2026-08-16 14:58:47 +08:00

478 lines
15 KiB
TypeScript

import type { PPTElement, RuntimeRecord, Whiteboard } from '@openmaic/dsl';
import { describe, expect, it } from 'vitest';
import { foldWhiteboardRuntimeRecords } from '@/lib/whiteboard/runtime/fold';
import {
LEGACY_WHITEBOARD_SOURCE_KIND,
WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
type LegacySnapshotImportedOperation,
type WhiteboardElementAddedOperation,
type WhiteboardRuntimePayloadV1,
} from '@/lib/whiteboard/runtime/types';
import {
cloneCanonicalJson,
normalizeAndValidateLegacyWhiteboard,
sha256Canonical,
validateWhiteboardRuntimePayload,
} from '@/lib/whiteboard/runtime/validate';
function board(overrides: Partial<Whiteboard> = {}): Whiteboard {
return {
id: 'board-1',
viewportSize: 1000,
viewportRatio: 0.5625,
elements: [
{
id: 'text-1',
type: 'text',
left: 10,
top: 20,
width: 300,
height: 60,
rotate: 0,
content: 'hello',
defaultFontName: 'Inter',
defaultColor: '#000000',
},
],
...overrides,
};
}
type LegacyPayload = WhiteboardRuntimePayloadV1 & {
operation: LegacySnapshotImportedOperation;
};
type ElementAddedPayload = WhiteboardRuntimePayloadV1 & {
operation: WhiteboardElementAddedOperation;
};
function textElement(id = 'text-added', content = 'learner text'): PPTElement {
return {
id,
type: 'text',
left: 40,
top: 50,
width: 240,
height: 60,
rotate: 0,
content,
defaultFontName: 'Inter',
defaultColor: '#000000',
};
}
function payload(overrides: Partial<LegacyPayload> = {}): LegacyPayload {
return {
payloadVersion: WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
operationId: 'legacy-import:one',
operation: {
kind: 'legacy_snapshot_imported',
source: {
kind: LEGACY_WHITEBOARD_SOURCE_KIND,
fingerprint: `sha256:${'0'.repeat(64)}`,
},
whiteboard: board(),
},
...overrides,
};
}
function elementPayload(
operationId = 'element-add:one',
element = textElement(),
): ElementAddedPayload {
return {
payloadVersion: WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
operationId,
operation: { kind: 'element_added', element },
};
}
function record(seq: number, value: WhiteboardRuntimePayloadV1 = payload()): RuntimeRecord {
return {
id: value.operationId,
sessionId: 'session-1',
seq,
createdAt: '2026-08-06T00:00:00.000Z',
payload: value,
};
}
describe('whiteboard RuntimeStore payload contract', () => {
it('accepts a canonical exact-key import payload', () => {
expect(validateWhiteboardRuntimePayload(payload())).toEqual({ valid: true });
});
it('accepts only the canonical tool-agnostic element_added shape', () => {
expect(validateWhiteboardRuntimePayload(elementPayload())).toEqual({ valid: true });
expect(
validateWhiteboardRuntimePayload({
...elementPayload(),
operation: { ...elementPayload().operation, whiteboardId: 'model-selected-board' },
}).valid,
).toBe(false);
expect(
validateWhiteboardRuntimePayload({
...elementPayload(),
operation: {
kind: 'element_added',
element: { ...textElement(), content: undefined },
},
}).valid,
).toBe(false);
expect(
validateWhiteboardRuntimePayload({
...elementPayload(),
operation: {
kind: 'element_added',
element: {
id: 'text-raw',
type: 'text',
left: 0,
top: 0,
width: 100,
height: 40,
rotate: 0,
},
},
}).valid,
).toBe(false);
});
it('requires contract fields to be own properties', () => {
const validateWithInheritedFields = (
inherited: Readonly<Record<string, unknown>>,
candidate: unknown,
) => {
const previous = new Map(
Object.keys(inherited).map((key) => [
key,
Object.getOwnPropertyDescriptor(Object.prototype, key),
]),
);
try {
for (const [key, value] of Object.entries(inherited)) {
Object.defineProperty(Object.prototype, key, {
value,
enumerable: true,
writable: true,
configurable: true,
});
}
return validateWhiteboardRuntimePayload(candidate);
} finally {
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(Object.prototype, key, descriptor);
else delete (Object.prototype as Record<string, unknown>)[key];
}
}
};
expect(
validateWithInheritedFields(
{
payloadVersion: WHITEBOARD_RUNTIME_PAYLOAD_VERSION,
operationId: 'inherited-operation',
operation: elementPayload().operation,
},
{},
).valid,
).toBe(false);
expect(
validateWithInheritedFields(
{ kind: 'element_added', element: textElement() },
{ ...elementPayload(), operation: {} },
).valid,
).toBe(false);
expect(
validateWithInheritedFields(
{ kind: LEGACY_WHITEBOARD_SOURCE_KIND, fingerprint: `sha256:${'0'.repeat(64)}` },
{ ...payload(), operation: { ...payload().operation, source: {} } },
).valid,
).toBe(false);
expect(
validateWithInheritedFields(
{ id: 'inherited-board', viewportSize: 1000, viewportRatio: 0.5625, elements: [] },
{ ...payload(), operation: { ...payload().operation, whiteboard: {} } },
).valid,
).toBe(false);
});
it.each([
['extra payload key', { ...payload(), extra: true }],
['unknown payload version', { ...payload(), payloadVersion: 2 }],
['unknown operation', { ...payload(), operation: { ...payload().operation, kind: 'draw' } }],
[
'duplicate element id',
{
...payload(),
operation: {
...payload().operation,
whiteboard: board({ elements: [board().elements[0]!, board().elements[0]!] }),
},
},
],
[
'non-finite geometry',
{
...payload(),
operation: {
...payload().operation,
whiteboard: board({
elements: [{ ...board().elements[0]!, left: Number.POSITIVE_INFINITY }],
}),
},
},
],
[
'unknown element kind',
{
...payload(),
operation: {
...payload().operation,
whiteboard: board({
elements: [{ ...board().elements[0]!, type: 'unknown' } as never],
}),
},
},
],
[
'non-canonical element',
{
...payload(),
operation: {
...payload().operation,
whiteboard: board({
elements: [
{
id: 'text-raw',
type: 'text',
left: 0,
top: 0,
width: 100,
height: 40,
rotate: 0,
} as never,
],
}),
},
},
],
])('rejects %s', (_label, value) => {
expect(validateWhiteboardRuntimePayload(value).valid).toBe(false);
});
it('normalizes a valid Legacy element before persistence', () => {
const raw = board({
elements: [
{
id: 'text-raw',
type: 'text',
left: 0,
top: 0,
width: 100,
height: 40,
rotate: 0,
} as never,
],
});
const normalized = normalizeAndValidateLegacyWhiteboard(raw);
expect(normalized.elements[0]).toMatchObject({
content: '',
defaultFontName: 'Microsoft YaHei',
defaultColor: '#333333',
});
});
it('accepts generated-schema tuple constraints for a normalized shape', () => {
const normalized = normalizeAndValidateLegacyWhiteboard(
board({
elements: [
{
id: 'shape-1',
type: 'shape',
left: 0,
top: 0,
width: 100,
height: 80,
rotate: 0,
} as never,
],
}),
);
expect(normalized.elements[0]).toMatchObject({
type: 'shape',
viewBox: [100, 80],
});
});
it('rejects non-JSON containers, sparse arrays, cycles, undefined, and unsafe strings', () => {
const dateValue = payload();
dateValue.operation.whiteboard.background = new Date() as never;
expect(validateWhiteboardRuntimePayload(dateValue).valid).toBe(false);
const sparseValue = payload();
const sparse = new Array(1) as Whiteboard['elements'];
sparseValue.operation.whiteboard.elements = sparse;
expect(validateWhiteboardRuntimePayload(sparseValue).valid).toBe(false);
const cyclicValue = payload();
(cyclicValue.operation.whiteboard as unknown as Record<string, unknown>).script = cyclicValue;
expect(validateWhiteboardRuntimePayload(cyclicValue).valid).toBe(false);
const undefinedValue = payload();
(undefinedValue.operation.whiteboard as unknown as Record<string, unknown>).script = undefined;
expect(validateWhiteboardRuntimePayload(undefinedValue).valid).toBe(false);
expect(validateWhiteboardRuntimePayload(payload({ operationId: 'unsafe\u0000id' })).valid).toBe(
false,
);
expect(
validateWhiteboardRuntimePayload(
payload({ operationId: `unsafe${String.fromCharCode(0xd800)}` }),
).valid,
).toBe(false);
});
it('canonicalizes key order and detaches caller aliases', async () => {
const source = payload();
const copy = cloneCanonicalJson(source);
const before = await sha256Canonical(copy);
source.operation.whiteboard.elements[0]!.id = 'mutated';
expect(copy.operation.whiteboard.elements[0]!.id).toBe('text-1');
expect(await sha256Canonical(copy)).toBe(before);
expect(await sha256Canonical({ b: 2, a: 1 })).toBe(await sha256Canonical({ a: 1, b: 2 }));
});
it('preserves own __proto__ keys as plain data during canonical cloning', () => {
const source = JSON.parse(
'{"nested":{"safe":true,"__proto__":0},"__proto__":"root"}',
) as Record<string, unknown>;
const copy = cloneCanonicalJson(source);
const nested = copy.nested as Record<string, unknown>;
expect(Object.getPrototypeOf(copy)).toBe(Object.prototype);
expect(Object.hasOwn(copy, '__proto__')).toBe(true);
expect(Object.getOwnPropertyDescriptor(copy, '__proto__')?.value).toBe('root');
expect(Object.getPrototypeOf(nested)).toBe(Object.prototype);
expect(Object.hasOwn(nested, '__proto__')).toBe(true);
expect(Object.getOwnPropertyDescriptor(nested, '__proto__')?.value).toBe(0);
});
});
describe('whiteboard RuntimeStore fold', () => {
it('folds an empty session shell without inventing state', async () => {
await expect(foldWhiteboardRuntimeRecords('session-1', [])).resolves.toMatchObject({
sessionId: 'session-1',
whiteboard: null,
lastSeq: null,
});
});
it('applies an exact duplicate operation once while retaining the real tail', async () => {
const result = await foldWhiteboardRuntimeRecords('session-1', [record(0), record(1)]);
expect(result.whiteboard?.id).toBe('board-1');
expect(result.lastSeq).toBe(1);
expect(Object.keys(result.operations)).toEqual(['legacy-import:one']);
expect(Object.isFrozen(result.whiteboard)).toBe(true);
expect(Object.isFrozen(result.operations)).toBe(true);
});
it('materializes and extends one deterministic session-owned learner board', async () => {
const sessionId = 'whiteboard:stage-1:private-learner';
const first = { ...record(0, elementPayload()), sessionId };
const second = {
...record(1, elementPayload('element-add:two', textElement('text-2', 'second'))),
sessionId,
};
const initial = await foldWhiteboardRuntimeRecords(sessionId, [first]);
const repeated = await foldWhiteboardRuntimeRecords(sessionId, [first]);
const otherSessionRecord = { ...first, sessionId: 'whiteboard:stage-1:other-learner' };
const other = await foldWhiteboardRuntimeRecords(otherSessionRecord.sessionId, [
otherSessionRecord,
]);
expect(initial.whiteboard).toMatchObject({
id: expect.stringMatching(/^runtime-whiteboard:[0-9a-f]{64}$/u),
viewportSize: 1000,
viewportRatio: 0.5625,
background: { type: 'solid', color: '#ffffff' },
animations: [],
elements: [{ id: 'text-added' }],
});
expect(initial.whiteboard?.id).toBe(repeated.whiteboard?.id);
expect(initial.whiteboard?.id).not.toBe(other.whiteboard?.id);
expect(initial.whiteboard?.id).not.toContain('private-learner');
const extended = await foldWhiteboardRuntimeRecords(sessionId, [first, second]);
expect(extended.whiteboard?.id).toBe(initial.whiteboard?.id);
expect(extended.whiteboard?.elements.map((element) => element.id)).toEqual([
'text-added',
'text-2',
]);
expect(Object.isFrozen(extended.whiteboard?.elements)).toBe(true);
});
it('preserves a Legacy board for learner adds and rejects invalid ordering or duplicates', async () => {
const legacyThenAdd = await foldWhiteboardRuntimeRecords('session-1', [
record(0, payload()),
record(1, elementPayload()),
]);
expect(legacyThenAdd.whiteboard?.id).toBe('board-1');
expect(legacyThenAdd.whiteboard?.elements.map((element) => element.id)).toEqual([
'text-1',
'text-added',
]);
await expect(
foldWhiteboardRuntimeRecords('session-1', [
record(0, elementPayload()),
record(1, payload()),
]),
).rejects.toThrow('WHITEBOARD_RUNTIME_IMPORT_AFTER_STATE');
await expect(
foldWhiteboardRuntimeRecords('session-1', [
record(0, elementPayload()),
record(1, elementPayload('element-add:two')),
]),
).rejects.toThrow('WHITEBOARD_RUNTIME_ELEMENT_ALREADY_EXISTS');
});
it('fails closed on conflicting duplicate, sequence, and session identity', async () => {
const conflicting = payload({
operation: { ...payload().operation, whiteboard: board({ id: 'board-2' }) },
});
await expect(
foldWhiteboardRuntimeRecords('session-1', [record(0), record(1, conflicting)]),
).rejects.toThrow('WHITEBOARD_RUNTIME_OPERATION_CONFLICT');
await expect(foldWhiteboardRuntimeRecords('session-1', [record(1)])).rejects.toThrow(
'WHITEBOARD_RUNTIME_RECORD_SEQUENCE_INVALID',
);
await expect(foldWhiteboardRuntimeRecords('other-session', [record(0)])).rejects.toThrow(
'WHITEBOARD_RUNTIME_RECORD_SESSION_MISMATCH',
);
await expect(
foldWhiteboardRuntimeRecords('session-1', [{ ...record(0), id: 'wrong-record-id' }]),
).rejects.toThrow('WHITEBOARD_RUNTIME_RECORD_OPERATION_ID_MISMATCH');
});
it('fails closed on malformed record envelopes and whiteboard-forbidden anchors', async () => {
await expect(
foldWhiteboardRuntimeRecords('session-1', [
{ ...record(0), createdAt: 'not-an-iso-timestamp' },
]),
).rejects.toThrow('WHITEBOARD_RUNTIME_RECORD_ENVELOPE_INVALID');
for (const anchored of [
{ ...record(0), sceneId: 'scene-1' },
{ ...record(0), actionIndex: 0 },
{ ...record(0), subAnchor: 'question-1' },
]) {
await expect(foldWhiteboardRuntimeRecords('session-1', [anchored])).rejects.toThrow(
'WHITEBOARD_RUNTIME_RECORD_ANCHOR_INVALID',
);
}
});
});