- Strip tools/schema.ts from 1831 to 65 lines (remove duplicate tool defs) - Fix tools/index.ts to export ALL_TOOLS from registry instead of schema - Fix renderJsx → renderJSX typo in tools/create.ts and structure.ts - Add geometry.ts with shared rotation/coordinate math utilities - Add isArrayMixed() helper to use-multi-props.ts, deduplicate FillSection/StrokeSection/EffectsSection - Fix broken test imports: use @open-pencil/core instead of relative paths into packages/core/src/ - Merge duplicate import statements across all test files - Export profiler internals and style-run helpers from core index.ts - Add 6 new test files: color, undo, snap, vector, style-runs, text-editor (98 new tests) - Update renderer-effects test for renderer/ directory split
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
import { describe, test, expect } from 'bun:test'
|
|
|
|
import { importNodeChanges, type NodeChange } from '@open-pencil/core'
|
|
|
|
function doc(): NodeChange {
|
|
return {
|
|
guid: { sessionID: 0, localID: 0 },
|
|
type: 'DOCUMENT',
|
|
name: 'Document',
|
|
visible: true,
|
|
opacity: 1,
|
|
phase: 'CREATED',
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
} as NodeChange
|
|
}
|
|
|
|
function canvas(localID = 1): NodeChange {
|
|
return {
|
|
guid: { sessionID: 0, localID },
|
|
parentIndex: { guid: { sessionID: 0, localID: 0 }, position: '!' },
|
|
type: 'CANVAS',
|
|
name: 'Page 1',
|
|
visible: true,
|
|
opacity: 1,
|
|
phase: 'CREATED',
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
} as NodeChange
|
|
}
|
|
|
|
function node(type: string, localID: number, parentLocalID: number, overrides: Partial<NodeChange> = {}): NodeChange {
|
|
return {
|
|
guid: { sessionID: 1, localID },
|
|
parentIndex: { guid: { sessionID: 0, localID: parentLocalID }, position: String.fromCharCode(33 + localID) },
|
|
type,
|
|
name: `${type}_${localID}`,
|
|
visible: true,
|
|
opacity: 1,
|
|
phase: 'CREATED',
|
|
size: { x: 100, y: 100 },
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
...overrides,
|
|
} as NodeChange
|
|
}
|
|
|
|
describe('fig-import: node types', () => {
|
|
test('ROUNDED_RECTANGLE imported as its own type', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('ROUNDED_RECTANGLE', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('ROUNDED_RECTANGLE')
|
|
})
|
|
|
|
test('COMPONENT maps to COMPONENT', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('COMPONENT', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('COMPONENT')
|
|
})
|
|
|
|
test('INSTANCE maps to INSTANCE', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('INSTANCE', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('INSTANCE')
|
|
})
|
|
|
|
test('SYMBOL maps to COMPONENT', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('SYMBOL', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('COMPONENT')
|
|
})
|
|
|
|
test('REGULAR_POLYGON maps to POLYGON', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('REGULAR_POLYGON', 10, 1)])
|
|
const nodes = graph.getChildren(graph.getPages()[0].id)
|
|
expect(nodes[0].type).toBe('POLYGON')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: gradient fills', () => {
|
|
test('linear gradient', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
fillPaints: [{
|
|
type: 'GRADIENT_LINEAR',
|
|
opacity: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL',
|
|
stops: [
|
|
{ color: { r: 1, g: 0, b: 0, a: 1 }, position: 0 },
|
|
{ color: { r: 0, g: 0, b: 1, a: 1 }, position: 1 },
|
|
],
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
}] as unknown as NodeChange['fillPaints'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fills).toHaveLength(1)
|
|
expect(n.fills[0].type).toBe('GRADIENT_LINEAR')
|
|
expect(n.fills[0].gradientStops).toHaveLength(2)
|
|
expect(n.fills[0].gradientStops![0].color.r).toBe(1)
|
|
expect(n.fills[0].gradientStops![1].color.b).toBe(1)
|
|
expect(n.fills[0].gradientTransform).toBeDefined()
|
|
})
|
|
|
|
test('radial gradient', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('ELLIPSE', 10, 1, {
|
|
fillPaints: [{
|
|
type: 'GRADIENT_RADIAL',
|
|
opacity: 0.8,
|
|
visible: true,
|
|
blendMode: 'NORMAL',
|
|
stops: [
|
|
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 0 },
|
|
{ color: { r: 0, g: 0, b: 0, a: 1 }, position: 1 },
|
|
],
|
|
transform: { m00: 0.5, m01: 0, m02: 0.5, m10: 0, m11: 0.5, m12: 0.5 },
|
|
}] as unknown as NodeChange['fillPaints'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fills[0].type).toBe('GRADIENT_RADIAL')
|
|
expect(n.fills[0].opacity).toBe(0.8)
|
|
expect(n.fills[0].gradientStops).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('fig-import: image fills', () => {
|
|
test('image fill with hash', () => {
|
|
const hash: Record<string, number> = {}
|
|
for (let i = 0; i < 20; i++) hash[String(i)] = i + 10
|
|
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
fillPaints: [{
|
|
type: 'IMAGE',
|
|
opacity: 1,
|
|
visible: true,
|
|
blendMode: 'NORMAL',
|
|
image: { hash, name: 'test-image' },
|
|
imageScaleMode: 'FILL',
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
}] as unknown as NodeChange['fillPaints'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fills[0].type).toBe('IMAGE')
|
|
expect(n.fills[0].imageHash).toBeDefined()
|
|
expect(n.fills[0].imageHash!.length).toBe(40)
|
|
expect(n.fills[0].imageScaleMode).toBe('FILL')
|
|
})
|
|
|
|
test('images stored on graph', () => {
|
|
const images = new Map<string, Uint8Array>()
|
|
images.set('abc123', new Uint8Array([1, 2, 3]))
|
|
const graph = importNodeChanges([doc(), canvas()], [], images)
|
|
expect(graph.images.get('abc123')).toEqual(new Uint8Array([1, 2, 3]))
|
|
})
|
|
})
|
|
|
|
describe('fig-import: effects', () => {
|
|
test('drop shadow', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
effects: [{
|
|
type: 'DROP_SHADOW',
|
|
color: { r: 0, g: 0, b: 0, a: 0.25 },
|
|
offset: { x: 4, y: 4 },
|
|
radius: 8,
|
|
spread: 0,
|
|
visible: true,
|
|
}] as unknown as NodeChange['effects'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.effects).toHaveLength(1)
|
|
expect(n.effects[0].type).toBe('DROP_SHADOW')
|
|
expect(n.effects[0].radius).toBe(8)
|
|
expect(n.effects[0].offset.x).toBe(4)
|
|
})
|
|
|
|
test('inner shadow', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
effects: [{
|
|
type: 'INNER_SHADOW',
|
|
color: { r: 0, g: 0, b: 0, a: 0.5 },
|
|
offset: { x: 0, y: 2 },
|
|
radius: 4,
|
|
spread: 0,
|
|
visible: true,
|
|
}] as unknown as NodeChange['effects'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.effects[0].type).toBe('INNER_SHADOW')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: stroke options', () => {
|
|
test('stroke cap and join', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('VECTOR', 10, 1, {
|
|
strokePaints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
|
strokeWeight: 3,
|
|
strokeAlign: 'CENTER',
|
|
strokeCap: 'ROUND',
|
|
strokeJoin: 'BEVEL',
|
|
} as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.strokes[0].cap).toBe('ROUND')
|
|
expect(n.strokes[0].join).toBe('BEVEL')
|
|
})
|
|
|
|
test('dash pattern', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
strokePaints: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }],
|
|
strokeWeight: 2,
|
|
strokeAlign: 'CENTER',
|
|
dashPattern: [10, 5],
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.strokes[0].dashPattern).toEqual([10, 5])
|
|
})
|
|
})
|
|
|
|
describe('fig-import: text properties', () => {
|
|
test('text auto resize', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('TEXT', 10, 1, {
|
|
textData: { characters: 'Hello' },
|
|
fontSize: 16,
|
|
textAlignHorizontal: 'CENTER',
|
|
textAutoResize: 'WIDTH_AND_HEIGHT',
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.textAutoResize).toBe('WIDTH_AND_HEIGHT')
|
|
expect(n.textAlignHorizontal).toBe('CENTER')
|
|
})
|
|
|
|
test('font weight mapping', () => {
|
|
const cases = [
|
|
['Bold', 700],
|
|
['Semi Bold', 600],
|
|
['Light', 300],
|
|
['ExtraBold', 800],
|
|
['Black', 900],
|
|
['Thin', 100],
|
|
['Medium', 500],
|
|
['Regular', 400],
|
|
] as const
|
|
|
|
for (const [style, expected] of cases) {
|
|
const graph = importNodeChanges([doc(), canvas(), node('TEXT', 10, 1, {
|
|
textData: { characters: 'X' },
|
|
fontName: { family: 'Inter', style },
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fontWeight).toBe(expected)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('fig-import: arc data', () => {
|
|
test('partial ellipse', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('ELLIPSE', 10, 1, {
|
|
arcData: {
|
|
startingAngle: 0,
|
|
endingAngle: Math.PI,
|
|
innerRadius: 0,
|
|
},
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.arcData).toBeDefined()
|
|
expect(n.arcData!.startingAngle).toBe(0)
|
|
expect(n.arcData!.endingAngle).toBeCloseTo(Math.PI)
|
|
expect(n.arcData!.innerRadius).toBe(0)
|
|
})
|
|
|
|
test('donut (inner radius)', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('ELLIPSE', 10, 1, {
|
|
arcData: {
|
|
startingAngle: 0,
|
|
endingAngle: Math.PI * 2,
|
|
innerRadius: 0.5,
|
|
},
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.arcData!.innerRadius).toBe(0.5)
|
|
})
|
|
})
|
|
|
|
describe('fig-import: constraints', () => {
|
|
test('horizontal and vertical constraints', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
horizontalConstraint: 'STRETCH',
|
|
verticalConstraint: 'CENTER',
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.horizontalConstraint).toBe('STRETCH')
|
|
expect(n.verticalConstraint).toBe('CENTER')
|
|
})
|
|
|
|
test('defaults to MIN', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.horizontalConstraint).toBe('MIN')
|
|
expect(n.verticalConstraint).toBe('MIN')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: blend mode', () => {
|
|
test('node blend mode', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
blendMode: 'MULTIPLY',
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.blendMode).toBe('MULTIPLY')
|
|
})
|
|
|
|
test('defaults to PASS_THROUGH', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.blendMode).toBe('PASS_THROUGH')
|
|
})
|
|
})
|
|
|
|
describe('fig-import: independent stroke weights', () => {
|
|
test('border weights imported', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('FRAME', 10, 1, {
|
|
borderTopWeight: 2,
|
|
borderRightWeight: 4,
|
|
borderBottomWeight: 2,
|
|
borderLeftWeight: 4,
|
|
borderStrokeWeightsIndependent: true,
|
|
} as unknown as Partial<NodeChange>)])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.borderTopWeight).toBe(2)
|
|
expect(n.borderRightWeight).toBe(4)
|
|
expect(n.independentStrokeWeights).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('fig-import: multiple fills', () => {
|
|
test('solid + gradient stacked', () => {
|
|
const graph = importNodeChanges([doc(), canvas(), node('RECTANGLE', 10, 1, {
|
|
fillPaints: [
|
|
{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' },
|
|
{
|
|
type: 'GRADIENT_LINEAR',
|
|
opacity: 0.5,
|
|
visible: true,
|
|
blendMode: 'NORMAL',
|
|
stops: [
|
|
{ color: { r: 1, g: 1, b: 1, a: 1 }, position: 0 },
|
|
{ color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 },
|
|
],
|
|
transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
|
|
},
|
|
] as unknown as NodeChange['fillPaints'],
|
|
})])
|
|
const n = graph.getChildren(graph.getPages()[0].id)[0]
|
|
expect(n.fills).toHaveLength(2)
|
|
expect(n.fills[0].type).toBe('SOLID')
|
|
expect(n.fills[1].type).toBe('GRADIENT_LINEAR')
|
|
expect(n.fills[1].opacity).toBe(0.5)
|
|
})
|
|
})
|
|
|
|
describe('fig-import: component set detection', () => {
|
|
test('FRAME with VARIANT componentPropDefs becomes COMPONENT_SET', () => {
|
|
const changes: NodeChange[] = [
|
|
doc(),
|
|
canvas(),
|
|
{
|
|
...node('FRAME', 10, 1),
|
|
name: 'Button',
|
|
componentPropDefs: [
|
|
{ id: { sessionID: 0, localID: 1 }, name: 'State', type: 'VARIANT' },
|
|
],
|
|
} as unknown as NodeChange,
|
|
{ ...node('SYMBOL', 11, 1), parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '!' }, name: 'State=Default' } as NodeChange,
|
|
{ ...node('SYMBOL', 12, 1), parentIndex: { guid: { sessionID: 1, localID: 10 }, position: '"' }, name: 'State=Hover' } as NodeChange,
|
|
]
|
|
const graph = importNodeChanges(changes, [])
|
|
const page = graph.getPages()[0]
|
|
const set = graph.getChildren(page.id)[0]
|
|
expect(set.type).toBe('COMPONENT_SET')
|
|
expect(set.name).toBe('Button')
|
|
const children = graph.getChildren(set.id)
|
|
expect(children).toHaveLength(2)
|
|
expect(children[0].type).toBe('COMPONENT')
|
|
expect(children[1].type).toBe('COMPONENT')
|
|
})
|
|
|
|
test('FRAME without componentPropDefs stays FRAME', () => {
|
|
const changes: NodeChange[] = [
|
|
doc(),
|
|
canvas(),
|
|
node('FRAME', 10, 1, { name: 'Regular Frame' }),
|
|
]
|
|
const graph = importNodeChanges(changes, [])
|
|
const page = graph.getPages()[0]
|
|
const frame = graph.getChildren(page.id)[0]
|
|
expect(frame.type).toBe('FRAME')
|
|
})
|
|
})
|