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

402 lines
16 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import Ajv from 'ajv';
import {
ELEMENT_DEFAULTS,
normalizeElement,
normalizeSlide,
normalizeSlideWith,
normalizeScene,
normalizeStage,
} from '@openmaic/dsl';
import type { PPTElement, Slide, Scene, Stage, SlideContent, Action } from '@openmaic/dsl';
// JS codegen helper (build-only); vitest/esbuild resolves it at runtime.
import { generateSchema } from '../scripts/gen-schema.mjs';
// A minimal, well-formed slide box shared by the element cases.
const box = { id: 'e1', left: 10, top: 20, width: 30, height: 40, rotate: 0 };
describe('normalizeElement — static defaults', () => {
it('fills missing text defaults with the canonical values', () => {
const out = normalizeElement({ ...box, type: 'text' }) as Extract<PPTElement, { type: 'text' }>;
expect(out.defaultFontName).toBe(ELEMENT_DEFAULTS.text.defaultFontName);
expect(out.defaultColor).toBe(ELEMENT_DEFAULTS.text.defaultColor);
expect(out.content).toBe(ELEMENT_DEFAULTS.text.content);
});
it('fills missing image / shape / line static defaults', () => {
const img = normalizeElement({ ...box, type: 'image', src: 's' }) as Extract<
PPTElement,
{ type: 'image' }
>;
expect(img.fixedRatio).toBe(ELEMENT_DEFAULTS.image.fixedRatio);
const shape = normalizeElement({ ...box, type: 'shape' }) as Extract<
PPTElement,
{ type: 'shape' }
>;
expect(shape.fill).toBe(ELEMENT_DEFAULTS.shape.fill);
expect(shape.fixedRatio).toBe(ELEMENT_DEFAULTS.shape.fixedRatio);
const line = normalizeElement({ ...box, type: 'line' }) as Extract<
PPTElement,
{ type: 'line' }
>;
expect(line.style).toBe(ELEMENT_DEFAULTS.line.style);
expect(line.color).toBe(ELEMENT_DEFAULTS.line.color);
expect(line.points).toEqual(ELEMENT_DEFAULTS.line.points);
});
it('does not clobber explicitly-set values', () => {
const out = normalizeElement({
...box,
type: 'text',
content: '<p>hi</p>',
defaultFontName: 'Inter',
defaultColor: '#111827',
}) as Extract<PPTElement, { type: 'text' }>;
expect(out.content).toBe('<p>hi</p>');
expect(out.defaultFontName).toBe('Inter');
expect(out.defaultColor).toBe('#111827');
});
it('treats an empty opinionated string as absent and defaults it', () => {
const out = normalizeElement({ ...box, type: 'text', defaultFontName: '' }) as Extract<
PPTElement,
{ type: 'text' }
>;
expect(out.defaultFontName).toBe(ELEMENT_DEFAULTS.text.defaultFontName);
});
it('keeps a `false` boolean (only `undefined` is absent)', () => {
const out = normalizeElement({ ...box, type: 'image', src: 's', fixedRatio: false }) as Extract<
PPTElement,
{ type: 'image' }
>;
expect(out.fixedRatio).toBe(false);
});
it("keeps a shape's explicit empty fill — '' means no solid fill, not absent", () => {
// Producers emit `fill: ''` deliberately (the importer for gradient /
// image-filled / unfilled shapes); the renderer maps it to `none`.
// Defaulting it would paint transparent shapes with the canonical color.
const shape = normalizeElement({ ...box, type: 'shape', fill: '' }) as Extract<
PPTElement,
{ type: 'shape' }
>;
expect(shape.fill).toBe('');
});
it('fills a present-but-empty shape text overlay (consumers read text.content unguarded)', () => {
const out = normalizeElement({ ...box, type: 'shape', text: {} }) as Extract<
PPTElement,
{ type: 'shape' }
>;
expect(out.text).toEqual(ELEMENT_DEFAULTS.shapeText);
});
it('keeps explicit shape text values and leaves an absent overlay absent', () => {
const filled = normalizeElement({
...box,
type: 'shape',
text: { content: '<p>hi</p>', defaultFontName: 'Inter', defaultColor: '#111', align: 'top' },
}) as Extract<PPTElement, { type: 'shape' }>;
expect(filled.text).toMatchObject({
content: '<p>hi</p>',
defaultFontName: 'Inter',
defaultColor: '#111',
align: 'top',
});
const absent = normalizeElement({ ...box, type: 'shape' }) as Extract<
PPTElement,
{ type: 'shape' }
>;
expect(absent.text).toBeUndefined();
});
it('throws on a malformed shape text overlay', () => {
expect(() => normalizeElement({ ...box, type: 'shape', text: 'hello' })).toThrow(/text/);
expect(() => normalizeElement({ ...box, type: 'shape', text: { content: 42 } })).toThrow(
/text\.content/,
);
expect(() => normalizeElement({ ...box, type: 'shape', text: { align: 'center' } })).toThrow(
/text\.align/,
);
});
});
describe('normalizeElement — derived geometry', () => {
it('derives a line start/end in the local frame (origin + a box-spanning segment, NOT absolute left/top)', () => {
const line = normalizeElement({ ...box, type: 'line' }) as Extract<
PPTElement,
{ type: 'line' }
>;
// Local to the element's (left, top) origin: start at the origin, end spans
// the box. Absolute [left, top] / [left+width, top] would double-offset it.
expect(line.start).toEqual([0, 0]);
expect(line.end).toEqual([30, 40]);
});
it('derives a shape viewBox (as a number pair) and a rectangle path from the box', () => {
const shape = normalizeElement({ ...box, type: 'shape', width: 100, height: 50 }) as Extract<
PPTElement,
{ type: 'shape' }
>;
expect(shape.viewBox).toEqual([100, 50]);
expect(shape.path).toBe('M0 0 L100 0 L100 50 L0 50 Z');
});
it('falls back to 0 for missing box geometry rather than throwing', () => {
const line = normalizeElement({ id: 'e', type: 'line' }) as Extract<
PPTElement,
{ type: 'line' }
>;
expect(line.start).toEqual([0, 0]);
expect(line.end).toEqual([0, 0]);
});
it('does not double-offset a line by its slide position (regression: absolute vs local)', () => {
const line = normalizeElement({
id: 'e',
type: 'line',
left: 500,
top: 300,
width: 80,
}) as Extract<PPTElement, { type: 'line' }>;
// The container is positioned at (left, top); start/end must stay local.
expect(line.start).toEqual([0, 0]);
expect(line.end).toEqual([80, 0]);
});
});
describe('normalizeElement — fail loud on malformed input', () => {
it('throws on a present-but-malformed line.points', () => {
expect(() => normalizeElement({ ...box, type: 'line', points: ['x', 'y'] })).toThrow(/points/);
expect(() => normalizeElement({ ...box, type: 'line', points: ['', '', ''] })).toThrow(
/points/,
);
});
it('throws on a string viewBox (the legacy `"0 0 w h"` shape the contract forbids)', () => {
expect(() => normalizeElement({ ...box, type: 'shape', viewBox: '0 0 30 40' })).toThrow(
/viewBox/,
);
});
it('throws on a wrong-typed static field', () => {
expect(() => normalizeElement({ ...box, type: 'image', src: 's', fixedRatio: 'yes' })).toThrow(
/fixedRatio/,
);
expect(() => normalizeElement({ ...box, type: 'text', defaultColor: 123 })).toThrow(
/defaultColor/,
);
expect(() => normalizeElement({ ...box, type: 'line', style: 'wavy' })).toThrow(/style/);
});
it('throws on a non-object or an unknown element type', () => {
expect(() => normalizeElement(null)).toThrow();
expect(() => normalizeElement({ id: 'e', type: 'bogus' })).toThrow(/unknown element type/);
});
it('passes non-defaulted element kinds through unchanged', () => {
const code = { ...box, type: 'code', language: 'py', lines: [] };
expect(normalizeElement(code)).toBe(code);
});
});
describe('normalizeElement — purity', () => {
it('never mutates its input', () => {
const input = Object.freeze({ ...box, type: 'text' });
expect(() => normalizeElement(input)).not.toThrow();
expect(input).not.toHaveProperty('defaultFontName');
});
it('is idempotent', () => {
const once = normalizeElement({ ...box, type: 'shape' });
const twice = normalizeElement(once);
expect(twice).toEqual(once);
});
});
// The Ajv-compiled scene schema DOES check element required fields (the pure
// validators are structural only), so it is the sharpest proof that a
// normalized element actually satisfies the published contract.
describe('normalized elements satisfy the JSON Schema contract', () => {
const validate = new Ajv({ allErrors: true, strict: false }).compile(
generateSchema('SerializedScene'),
);
const sceneWith = (elements: unknown[]) => ({
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('rejects an under-specified element but accepts it once normalized', () => {
const bare = { ...box, type: 'text' }; // missing defaultFontName / defaultColor / content
expect(validate(sceneWith([bare]))).toBe(false);
expect(validate(sceneWith([normalizeElement(bare)]))).toBe(true);
});
it('accepts a bare line/shape once normalized (derived + static fields filled)', () => {
// A line omits `height` / `rotate` (see PPTLineElement), so build it from a
// line-shaped box rather than the full base box.
const els = [
{ id: 'l', left: 10, top: 20, width: 30, type: 'line' },
{ ...box, id: 's', type: 'shape' },
].map(normalizeElement);
expect(validate(sceneWith(els))).toBe(true);
});
});
describe('document-level walkers', () => {
const bareText = { ...box, type: 'text' } as unknown as PPTElement;
const slide = (elements: PPTElement[]): Slide => ({
id: 'c',
viewportSize: 1920,
viewportRatio: 0.5625,
theme: { themeColors: [], fontColor: '#000', fontName: 'Arial', backgroundColor: '#fff' },
elements,
});
it('normalizeSlide normalizes every element', () => {
const out = normalizeSlide(slide([bareText]));
expect((out.elements[0] as Extract<PPTElement, { type: 'text' }>).defaultFontName).toBe(
ELEMENT_DEFAULTS.text.defaultFontName,
);
});
it('normalizeSlide throws on a malformed element (use normalizeSlideWith to degrade)', () => {
const bad = { ...box, type: 'text', defaultColor: 123 } as unknown as PPTElement;
expect(() => normalizeSlide(slide([bad]))).toThrow(/defaultColor/);
});
it('normalizeSlide stays unary: point-free `slides.map(normalizeSlide)` type-checks and works', () => {
// Regression for the API-ergonomics contract: an options parameter on
// normalizeSlide itself would collide with map's index argument.
const out = [slide([bareText]), slide([bareText])].map(normalizeSlide);
expect(out).toHaveLength(2);
expect((out[1].elements[0] as Extract<PPTElement, { type: 'text' }>).defaultFontName).toBe(
ELEMENT_DEFAULTS.text.defaultFontName,
);
});
it("normalizeSlideWith onInvalid: 'drop' drops the malformed element, keeps the rest, and reports it", () => {
const bad = { ...box, id: 'bad', type: 'text', defaultColor: 123 } as unknown as PPTElement;
const dropped: Array<{ element: unknown; error: unknown }> = [];
const out = normalizeSlideWith({
onInvalid: 'drop',
onDropped: (element, error) => dropped.push({ element, error }),
})(slide([bareText, bad]));
expect(out.elements).toHaveLength(1);
expect(out.elements[0].id).toBe(box.id);
expect(dropped).toHaveLength(1);
expect((dropped[0].element as { id: string }).id).toBe('bad');
expect(String(dropped[0].error)).toMatch(/defaultColor/);
});
it("normalizeSlideWith onInvalid: 'drop' without onDropped drops without throwing", () => {
const bad = { ...box, type: 'text', defaultColor: 123 } as unknown as PPTElement;
const normalize = normalizeSlideWith({ onInvalid: 'drop' });
expect(() => normalize(slide([bad]))).not.toThrow();
expect(normalize(slide([bad])).elements).toHaveLength(0);
});
it('normalizeSlideWith without a drop policy is plain normalizeSlide (map-safe)', () => {
expect(normalizeSlideWith({})).toBe(normalizeSlide);
const bad = { ...box, type: 'text', defaultColor: 123 } as unknown as PPTElement;
expect(() => [slide([bad])].map(normalizeSlideWith({}))).toThrow(/defaultColor/);
});
it('normalizeScene normalizes a slide scene canvas + whiteboards', () => {
const scene: Scene = {
id: 'sc',
stageId: 'st',
title: 't',
order: 0,
type: 'slide',
content: { type: 'slide', canvas: slide([bareText]) },
whiteboards: [slide([bareText])],
};
const out = normalizeScene(scene);
const canvasEl = (out as Extract<Scene, { type: 'slide' }>).content.canvas
.elements[0] as Extract<PPTElement, { type: 'text' }>;
const wbEl = out.whiteboards![0].elements[0] as Extract<PPTElement, { type: 'text' }>;
expect(canvasEl.defaultFontName).toBe(ELEMENT_DEFAULTS.text.defaultFontName);
expect(wbEl.defaultFontName).toBe(ELEMENT_DEFAULTS.text.defaultFontName);
});
it('normalizeScene is callable on app-widened scenes and passes non-slide content through', () => {
// App widens Scene's content with its own kinds (interactive / pbl). The
// generic signature must accept them; non-slide content has no canvas to
// normalize and passes through untouched.
type AppContent = SlideContent | { type: 'interactive'; widget: string };
const scene = {
id: 'sc',
stageId: 'st',
title: 't',
order: 0,
type: 'interactive',
content: { type: 'interactive', widget: 'w' },
whiteboards: [slide([bareText])],
} as unknown as Scene<Action, AppContent>;
const out = normalizeScene(scene);
expect((out.content as { widget: string }).widget).toBe('w');
// whiteboards still get normalized even on a non-slide scene.
expect(
(out.whiteboards![0].elements[0] as Extract<PPTElement, { type: 'text' }>).defaultFontName,
).toBe(ELEMENT_DEFAULTS.text.defaultFontName);
});
it('normalizeStage normalizes every whiteboard', () => {
const stage: Stage = {
id: 'st',
name: 'n',
createdAt: 1,
updatedAt: 2,
whiteboard: [slide([bareText])],
};
const out = normalizeStage(stage);
expect(
(out.whiteboard![0].elements[0] as Extract<PPTElement, { type: 'text' }>).defaultFontName,
).toBe(ELEMENT_DEFAULTS.text.defaultFontName);
});
});
// Pin the runtime defaults (ELEMENT_DEFAULTS, consumed by normalize) to the
// `@default` annotations the schema ships (consumed by non-TS validators). The
// two are separate sources — a JSDoc literal and a TS constant — so without this
// they could silently drift. The schema, derived from the annotated types, is
// the published side; ELEMENT_DEFAULTS must match it field for field.
describe('ELEMENT_DEFAULTS stays in lockstep with the schema `default` annotations', () => {
const scene = generateSchema('SerializedScene') as {
definitions: Record<string, { properties?: Record<string, { default?: unknown }> }>;
};
const defaultsOf = (typeName: string): Record<string, unknown> => {
const props = scene.definitions[typeName]?.properties ?? {};
const out: Record<string, unknown> = {};
for (const [field, spec] of Object.entries(props)) {
if ('default' in spec) out[field] = spec.default;
}
return out;
};
it('text', () => expect(defaultsOf('PPTTextElement')).toEqual(ELEMENT_DEFAULTS.text));
it('image', () => expect(defaultsOf('PPTImageElement')).toEqual(ELEMENT_DEFAULTS.image));
it('shape', () => expect(defaultsOf('PPTShapeElement')).toEqual(ELEMENT_DEFAULTS.shape));
it('line', () => expect(defaultsOf('PPTLineElement')).toEqual(ELEMENT_DEFAULTS.line));
it('shape text overlay', () =>
expect(defaultsOf('ShapeText')).toEqual(ELEMENT_DEFAULTS.shapeText));
});