Clean up tools/schema, add geometry.ts, isMixed helper, fix imports, add tests

- 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
This commit is contained in:
Danila Poyarkov 2026-03-06 22:33:10 +03:00
parent 859143149e
commit 9784544f1f
27 changed files with 1030 additions and 1833 deletions

View file

@ -0,0 +1,73 @@
import type { Vector } from './types'
export function degToRad(degrees: number): number {
return (degrees * Math.PI) / 180
}
export function radToDeg(radians: number): number {
return (radians * 180) / Math.PI
}
export function rotatePoint(
px: number,
py: number,
cx: number,
cy: number,
rad: number
): Vector {
const cos = Math.cos(rad)
const sin = Math.sin(rad)
return {
x: cx + (px - cx) * cos - (py - cy) * sin,
y: cy + (px - cx) * sin + (py - cy) * cos
}
}
export function rotatedCorners(
cx: number,
cy: number,
hw: number,
hh: number,
rotationDeg: number
): [Vector, Vector, Vector, Vector] {
const rad = degToRad(rotationDeg)
const cos = Math.cos(rad)
const sin = Math.sin(rad)
return [
{ x: cx + -hw * cos - -hh * sin, y: cy + -hw * sin + -hh * cos },
{ x: cx + hw * cos - -hh * sin, y: cy + hw * sin + -hh * cos },
{ x: cx + hw * cos - hh * sin, y: cy + hw * sin + hh * cos },
{ x: cx + -hw * cos - hh * sin, y: cy + -hw * sin + hh * cos }
]
}
export function rotatedBBox(
x: number,
y: number,
w: number,
h: number,
rotationDeg: number
): { left: number; right: number; top: number; bottom: number; centerX: number; centerY: number } {
if (rotationDeg === 0) {
return {
left: x,
right: x + w,
top: y,
bottom: y + h,
centerX: x + w / 2,
centerY: y + h / 2
}
}
const corners = rotatedCorners(x + w / 2, y + h / 2, w / 2, h / 2, rotationDeg)
let left = Infinity,
right = -Infinity,
top = Infinity,
bottom = -Infinity
for (const c of corners) {
left = Math.min(left, c.x)
right = Math.max(right, c.x)
top = Math.min(top, c.y)
bottom = Math.max(bottom, c.y)
}
return { left, right, top, bottom, centerX: (left + right) / 2, centerY: (top + bottom) / 2 }
}

View file

@ -50,7 +50,15 @@ export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api'
export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
export type { ToolDef, ParamDef, ParamType } from './tools'
export { SkiaRenderer, type RenderOverlays } from './renderer/index'
export { RenderProfiler } from './profiler'
export {
RenderProfiler,
FrameStats,
GPUTimer,
DrawCallCounter,
PhaseTimer,
CaptureStack,
toSpeedscopeJSON
} from './profiler'
export type { FrameCapture, NodeProfile } from './profiler'
export { computeLayout, computeAllLayouts, setTextMeasurer } from './layout'
export type { TextMeasurer } from './layout'
@ -81,6 +89,10 @@ export { computeSelectionBounds, computeSnap, type SnapGuide } from './snap'
export { UndoManager } from './undo'
export { TextEditor, type TextCaret, type TextEditorState } from './text-editor'
export {
getStyleAt,
applyStyleToRange,
removeStyleFromRange,
selectionHasStyle,
toggleBoldInRange,
toggleItalicInRange,
toggleDecorationInRange,

View file

@ -58,8 +58,8 @@ export const render = defineTool({
parent_id: { type: 'string', description: 'Parent node ID to render into' }
},
execute: async (figma, args) => {
const { renderJsx } = await import('../render/render-jsx.js')
const result = await renderJsx(figma.graph, args.jsx, {
const { renderJSX } = await import('../render/render-jsx.js')
const result = await renderJSX(figma.graph, args.jsx, {
parentId: args.parent_id ?? figma.currentPageId,
x: args.x,
y: args.y

View file

@ -1,3 +1,4 @@
export { ALL_TOOLS, defineTool } from './schema'
export { ALL_TOOLS } from './registry'
export { defineTool, nodeToResult, nodeSummary } from './schema'
export type { ToolDef, ParamDef, ParamType } from './schema'
export { toolsToAI } from './ai-adapter'

File diff suppressed because it is too large Load diff

View file

@ -267,8 +267,8 @@ export const nodeReplaceWith = defineTool({
const x = node.x
const y = node.y
node.remove()
const { renderJsx } = await import('../render/render-jsx.js')
const result = await renderJsx(figma.graph, args.jsx, { parentId, x, y })
const { renderJSX } = await import('../render/render-jsx.js')
const result = await renderJSX(figma.graph, args.jsx, { parentId, x, y })
return { id: result.id, name: result.name, type: result.type }
}
})

View file

@ -11,14 +11,9 @@ import { colorToCSS } from '@open-pencil/core'
import type { Color, Effect } from '@open-pencil/core'
const { store } = useNodeProps()
const { node, nodes, isMulti, active } = useMultiProps()
const { node, nodes, isMulti, active, isArrayMixed } = useMultiProps()
const effectsAreMixed = computed(() => {
if (!isMulti.value) return false
const all = nodes.value
const first = JSON.stringify(all[0].effects)
return all.some((n) => JSON.stringify(n.effects) !== first)
})
const effectsAreMixed = computed(() => isArrayMixed('effects'))
const expandedIndex = ref<number | null>(null)
const effectsBeforeScrub = ref<Effect[] | null>(null)

View file

@ -22,14 +22,9 @@ import { colorToCSS, colorToHexRaw } from '@open-pencil/core'
import type { Fill, Variable, Color } from '@open-pencil/core'
const { store } = useNodeProps()
const { nodes, isMulti, active, activeNode } = useMultiProps()
const { nodes, isMulti, active, activeNode, isArrayMixed } = useMultiProps()
const fillsAreMixed = computed(() => {
if (!isMulti.value) return false
const all = nodes.value
const first = JSON.stringify(all[0].fills)
return all.some((n) => JSON.stringify(n.fills) !== first)
})
const fillsAreMixed = computed(() => isArrayMixed('fills'))
const colorVariables = computed(() => store.graph.getVariablesByType('COLOR'))

View file

@ -20,14 +20,9 @@ import type { SceneNode } from '@open-pencil/core'
type StrokeSides = 'ALL' | 'TOP' | 'BOTTOM' | 'LEFT' | 'RIGHT' | 'CUSTOM'
const { store } = useNodeProps()
const { nodes, isMulti, active, activeNode } = useMultiProps()
const { nodes, isMulti, active, activeNode, isArrayMixed } = useMultiProps()
const strokesAreMixed = computed(() => {
if (!isMulti.value) return false
const all = nodes.value
const first = JSON.stringify(all[0].strokes)
return all.some((n) => JSON.stringify(n.strokes) !== first)
})
const strokesAreMixed = computed(() => isArrayMixed('strokes'))
const ALIGN_OPTIONS: { value: Stroke['align']; label: string }[] = [
{ value: 'INSIDE', label: 'Inside' },

View file

@ -36,5 +36,23 @@ export function useMultiProps() {
store.requestRender()
}
return { store, node, nodes, isMulti, active, activeNode, prop, merged, updateAllWithUndo }
function isArrayMixed<K extends keyof SceneNode>(key: K): boolean {
const all = nodes.value
if (all.length <= 1) return false
const first = JSON.stringify(all[0][key])
return all.some((n) => JSON.stringify(n[key]) !== first)
}
return {
store,
node,
nodes,
isMulti,
active,
activeNode,
prop,
merged,
updateAllWithUndo,
isArrayMixed
}
}

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '../../packages/core/src/scene-graph'
import { SceneGraph } from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id

View file

@ -5,9 +5,10 @@ import {
importClipboardNodes,
figmaNodesBounds,
buildFigmaClipboardHTML,
} from '../../packages/core/src/clipboard'
import { initCodec } from '../../packages/core/src/kiwi/codec'
import { SceneGraph, type SceneNode } from '../../packages/core/src/scene-graph'
initCodec,
SceneGraph,
type SceneNode,
} from '@open-pencil/core'
function makeClipboardHtml(nodeChanges: unknown[], meta = { fileKey: 'test', pasteID: 1, dataType: 'scene' }) {
// Minimal fig-kiwi clipboard: just meta + empty figma buffer

117
tests/engine/color.test.ts Normal file
View file

@ -0,0 +1,117 @@
import { describe, test, expect } from 'bun:test'
import {
parseColor,
colorToHex,
colorToHexRaw,
colorToRgba255,
colorToCSS
} from '@open-pencil/core'
describe('parseColor', () => {
test('hex 6-digit', () => {
const c = parseColor('#FF0000')
expect(c.r).toBeCloseTo(1)
expect(c.g).toBeCloseTo(0)
expect(c.b).toBeCloseTo(0)
expect(c.a).toBe(1)
})
test('hex 3-digit', () => {
const c = parseColor('#f00')
expect(c.r).toBeCloseTo(1)
expect(c.g).toBeCloseTo(0)
expect(c.b).toBeCloseTo(0)
})
test('hex 8-digit with alpha', () => {
const c = parseColor('#FF000080')
expect(c.r).toBeCloseTo(1)
expect(c.a).toBeCloseTo(0.5, 1)
})
test('rgb()', () => {
const c = parseColor('rgb(255, 0, 0)')
expect(c.r).toBeCloseTo(1)
expect(c.g).toBeCloseTo(0)
expect(c.b).toBeCloseTo(0)
expect(c.a).toBe(1)
})
test('rgba()', () => {
const c = parseColor('rgba(255, 0, 0, 0.5)')
expect(c.r).toBeCloseTo(1)
expect(c.a).toBeCloseTo(0.5)
})
test('named color', () => {
const c = parseColor('blue')
expect(c.b).toBeCloseTo(1)
expect(c.r).toBeCloseTo(0)
expect(c.a).toBe(1)
})
test('white', () => {
const c = parseColor('#FFFFFF')
expect(c.r).toBeCloseTo(1)
expect(c.g).toBeCloseTo(1)
expect(c.b).toBeCloseTo(1)
})
test('invalid returns black fallback', () => {
const c = parseColor('invalid')
expect(c.r).toBe(0)
expect(c.g).toBe(0)
expect(c.b).toBe(0)
expect(c.a).toBe(1)
})
test('empty string returns black fallback', () => {
const c = parseColor('')
expect(c.r).toBe(0)
expect(c.g).toBe(0)
expect(c.b).toBe(0)
})
})
describe('colorToHex', () => {
test('red', () => {
expect(colorToHex({ r: 1, g: 0, b: 0, a: 1 })).toBe('#FF0000')
})
test('black', () => {
expect(colorToHex({ r: 0, g: 0, b: 0, a: 1 })).toBe('#000000')
})
test('white', () => {
expect(colorToHex({ r: 1, g: 1, b: 1, a: 1 })).toBe('#FFFFFF')
})
})
describe('colorToHexRaw', () => {
test('strips # prefix', () => {
expect(colorToHexRaw({ r: 1, g: 0, b: 0, a: 1 })).toBe('FF0000')
})
})
describe('colorToRgba255', () => {
test('converts 0-1 to 0-255', () => {
const result = colorToRgba255({ r: 1, g: 0.5, b: 0, a: 0.8 })
expect(result.r).toBe(255)
expect(result.g).toBe(128)
expect(result.b).toBe(0)
expect(result.a).toBe(0.8)
})
})
describe('colorToCSS', () => {
test('opaque uses rgb()', () => {
const result = colorToCSS({ r: 1, g: 0, b: 0, a: 1 })
expect(result).toBe('rgb(255, 0, 0)')
})
test('transparent uses rgba()', () => {
const result = colorToCSS({ r: 1, g: 0, b: 0, a: 0.5 })
expect(result).toBe('rgba(255, 0, 0, 0.5)')
})
})

View file

@ -1,8 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { importNodeChanges } from '../../src/kiwi/fig-import'
import type { NodeChange } from '../../src/kiwi/codec'
import { importNodeChanges, type NodeChange } from '@open-pencil/core'
function doc(): NodeChange {
return {

View file

@ -2,13 +2,16 @@ import { describe, test, expect, beforeAll, setDefaultTimeout } from 'bun:test'
import { readFileSync } from 'fs'
import { resolve } from 'path'
import { parseFigFile } from '../../packages/core/src/kiwi/fig-file'
import { exportFigFile } from '../../packages/core/src/fig-export'
import { importNodeChanges } from '../../packages/core/src/kiwi/fig-import'
import { initCodec } from '../../packages/core/src/kiwi/codec'
import { SceneGraph } from '../../packages/core/src/scene-graph'
import type { SceneNode, NodeType, Fill } from '../../packages/core/src/scene-graph'
import {
parseFigFile,
exportFigFile,
importNodeChanges,
initCodec,
SceneGraph,
type SceneNode,
type NodeType,
type Fill,
} from '@open-pencil/core'
import { heavy } from '../helpers/test-utils'
setDefaultTimeout(60_000)

View file

@ -6,8 +6,8 @@ import {
markFontLoaded,
styleToWeight,
weightToStyle,
} from '../../packages/core/src/fonts'
import { SceneGraph } from '../../packages/core/src/scene-graph'
SceneGraph,
} from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id

View file

@ -1,10 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph } from '../../packages/core/src/scene-graph'
import { FigmaAPI } from '../../packages/core/src/figma-api'
import { sceneNodeToKiwi } from '../../packages/core/src/kiwi-serialize'
import type { Stroke } from '../../packages/core/src/scene-graph'
import { SceneGraph, FigmaAPI, sceneNodeToKiwi, type Stroke } from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id

View file

@ -1,7 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph, type SceneNode } from '../../src/engine/scene-graph'
import { computeLayout, computeAllLayouts, setTextMeasurer } from '../../src/engine/layout'
import { SceneGraph, type SceneNode, computeLayout, computeAllLayouts, setTextMeasurer } from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id

View file

@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph } from '../../packages/core/src/scene-graph'
import { SceneGraph } from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id

View file

@ -1,9 +1,5 @@
import { describe, it, expect } from 'bun:test'
import { FrameStats } from '../../packages/core/src/profiler/frame-stats'
import { DrawCallCounter } from '../../packages/core/src/profiler/draw-call-counter'
import { PhaseTimer } from '../../packages/core/src/profiler/phase-timer'
import { GPUTimer } from '../../packages/core/src/profiler/gpu-timer'
import { CaptureStack, toSpeedscopeJSON } from '../../packages/core/src/profiler/frame-capture'
import { FrameStats, DrawCallCounter, PhaseTimer, GPUTimer, CaptureStack, toSpeedscopeJSON } from '@open-pencil/core'
describe('FrameStats', () => {
it('records frames and computes rolling averages', () => {

View file

@ -1,21 +1,25 @@
import { describe, test, expect } from 'bun:test'
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
const rendererSource = await Bun.file(
new URL('../../packages/core/src/renderer.ts', import.meta.url)
).text()
const rendererDir = new URL('../../packages/core/src/renderer/', import.meta.url).pathname
const files = await readdir(rendererDir)
const parts = await Promise.all(
files.filter((f) => f.endsWith('.ts')).map((f) => Bun.file(join(rendererDir, f)).text())
)
const rendererSource = parts.join('\n')
describe('Renderer effect ordering', () => {
test('drop shadow renders before fills', () => {
// In renderShape, "Effects (behind: drop shadow)" must come before "Fills"
const behindIdx = rendererSource.indexOf("renderEffects(canvas, node, rect, hasRadius, 'behind')")
const fillsIdx = rendererSource.indexOf('this.drawNodeFill(canvas, node, rect, hasRadius)')
const fillsIdx = rendererSource.indexOf('drawNodeFill(canvas, node, rect, hasRadius)')
expect(behindIdx).toBeGreaterThan(-1)
expect(fillsIdx).toBeGreaterThan(-1)
expect(behindIdx).toBeLessThan(fillsIdx)
})
test('inner shadow and blur render after strokes', () => {
const strokeIdx = rendererSource.indexOf('this.drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)')
const strokeIdx = rendererSource.indexOf('drawStrokeWithAlign(canvas, node, rect, hasRadius, stroke.align)')
const frontIdx = rendererSource.indexOf("renderEffects(canvas, node, rect, hasRadius, 'front')")
expect(strokeIdx).toBeGreaterThan(-1)
expect(frontIdx).toBeGreaterThan(-1)
@ -65,11 +69,11 @@ describe('Shadow spread support', () => {
})
test('makeRRectWithSpread helper exists', () => {
expect(rendererSource).toContain('private makeRRectWithSpread')
expect(rendererSource).toContain('function makeRRectWithSpread')
})
test('makeRRectWithOffset helper exists', () => {
expect(rendererSource).toContain('private makeRRectWithOffset')
expect(rendererSource).toContain('function makeRRectWithOffset')
})
})
@ -110,7 +114,7 @@ describe('Blur effects use saveLayer pattern', () => {
})
test('applyClippedBlur clips to node shape and uses saveLayer', () => {
const idx = rendererSource.indexOf('private applyClippedBlur')
const idx = rendererSource.indexOf('function applyClippedBlur')
const section = rendererSource.slice(idx, idx + 500)
expect(section).toContain('clipNodeShape')
expect(section).toContain('getCachedBlur')

View file

@ -1,6 +1,6 @@
import { describe, test, expect } from 'bun:test'
import { SceneGraph } from '../../src/engine/scene-graph'
import { SceneGraph } from '@open-pencil/core'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id

89
tests/engine/snap.test.ts Normal file
View file

@ -0,0 +1,89 @@
import { describe, test, expect } from 'bun:test'
import { computeSnap, computeSelectionBounds, type SceneNode } from '@open-pencil/core'
function node(overrides: Partial<SceneNode> & { id: string }): SceneNode {
return { x: 0, y: 0, width: 100, height: 100, rotation: 0, ...overrides } as SceneNode
}
describe('computeSelectionBounds', () => {
test('empty array returns null', () => {
expect(computeSelectionBounds([])).toBeNull()
})
test('single node', () => {
const bounds = computeSelectionBounds([node({ id: 'a', x: 10, y: 20, width: 100, height: 50 })])
expect(bounds).toEqual({ x: 10, y: 20, width: 100, height: 50 })
})
test('two nodes gives union', () => {
const bounds = computeSelectionBounds([
node({ id: 'a', x: 0, y: 0, width: 50, height: 50 }),
node({ id: 'b', x: 100, y: 100, width: 50, height: 50 })
])
expect(bounds).toEqual({ x: 0, y: 0, width: 150, height: 150 })
})
test('rotated node expands bbox', () => {
const bounds = computeSelectionBounds([
node({ id: 'a', x: 0, y: 0, width: 100, height: 0, rotation: 45 })
])!
expect(bounds.width).toBeGreaterThan(0)
expect(bounds.height).toBeGreaterThan(0)
})
})
describe('computeSnap', () => {
test('no targets returns zero', () => {
const result = computeSnap(
new Set(['moving']),
{ x: 50, y: 50, width: 100, height: 100 },
[node({ id: 'moving' })]
)
expect(result.dx).toBe(0)
expect(result.dy).toBe(0)
expect(result.guides).toHaveLength(0)
})
test('snaps to aligned left edge', () => {
const target = node({ id: 'target', x: 100, y: 200, width: 50, height: 50 })
const result = computeSnap(
new Set(['moving']),
{ x: 102, y: 50, width: 80, height: 80 },
[node({ id: 'moving' }), target]
)
expect(result.dx).toBe(-2)
})
test('snaps to aligned top edge', () => {
const target = node({ id: 'target', x: 200, y: 100, width: 50, height: 50 })
const result = computeSnap(
new Set(['moving']),
{ x: 50, y: 103, width: 80, height: 80 },
[node({ id: 'moving' }), target]
)
expect(result.dy).toBe(-3)
})
test('no snap when far away', () => {
const target = node({ id: 'target', x: 500, y: 500, width: 50, height: 50 })
const result = computeSnap(
new Set(['moving']),
{ x: 0, y: 0, width: 50, height: 50 },
[node({ id: 'moving' }), target]
)
expect(result.dx).toBe(0)
expect(result.dy).toBe(0)
})
test('center-to-center snap', () => {
// target center at x=50, moving center at x=-2+50=48 → dx=+2
const target = node({ id: 'target', x: 0, y: 0, width: 100, height: 100 })
const result = computeSnap(
new Set(['moving']),
{ x: -2, y: 300, width: 100, height: 100 },
[node({ id: 'moving' }), target]
)
expect(result.dx).toBe(2)
})
})

View file

@ -0,0 +1,157 @@
import { describe, test, expect } from 'bun:test'
import {
getStyleAt,
applyStyleToRange,
removeStyleFromRange,
selectionHasStyle,
adjustRunsForInsert,
adjustRunsForDelete,
toggleBoldInRange,
toggleItalicInRange,
toggleDecorationInRange,
type StyleRun,
} from '@open-pencil/core'
describe('getStyleAt', () => {
test('returns style within run', () => {
const runs: StyleRun[] = [{ start: 2, length: 3, style: { fontWeight: 700 } }]
expect(getStyleAt(runs, 3)).toEqual({ fontWeight: 700 })
})
test('returns empty object outside runs', () => {
const runs: StyleRun[] = [{ start: 5, length: 2, style: { fontWeight: 700 } }]
expect(getStyleAt(runs, 0)).toEqual({})
})
})
describe('applyStyleToRange', () => {
test('applies bold to range', () => {
const runs = applyStyleToRange([], 2, 5, { fontWeight: 700 }, 10)
expect(runs).toHaveLength(1)
expect(runs[0].start).toBe(2)
expect(runs[0].length).toBe(3)
expect(runs[0].style.fontWeight).toBe(700)
})
test('merges with existing style', () => {
const existing: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700 } }]
const runs = applyStyleToRange(existing, 2, 4, { italic: true }, 10)
const style = getStyleAt(runs, 3)
expect(style.fontWeight).toBe(700)
expect(style.italic).toBe(true)
})
})
describe('removeStyleFromRange', () => {
test('removes keys from range', () => {
const existing: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700, italic: true } }]
const runs = removeStyleFromRange(existing, 0, 5, ['fontWeight'], 10)
const style = getStyleAt(runs, 2)
expect(style.fontWeight).toBeUndefined()
expect(style.italic).toBe(true)
})
})
describe('selectionHasStyle', () => {
test('all chars have style', () => {
const runs: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700 } }]
expect(selectionHasStyle(runs, 0, 5, 'fontWeight', 700)).toBe(true)
})
test('some chars missing', () => {
const runs: StyleRun[] = [{ start: 0, length: 3, style: { fontWeight: 700 } }]
expect(selectionHasStyle(runs, 0, 5, 'fontWeight', 700)).toBe(false)
})
})
describe('adjustRunsForInsert', () => {
test('insert before run shifts start', () => {
const runs: StyleRun[] = [{ start: 5, length: 3, style: { fontWeight: 700 } }]
const adjusted = adjustRunsForInsert(runs, 2, 4)
expect(adjusted[0].start).toBe(9)
expect(adjusted[0].length).toBe(3)
})
test('insert inside run extends length', () => {
const runs: StyleRun[] = [{ start: 2, length: 6, style: { fontWeight: 700 } }]
const adjusted = adjustRunsForInsert(runs, 4, 3)
expect(adjusted[0].start).toBe(2)
expect(adjusted[0].length).toBe(9)
})
test('insert after run leaves it unchanged', () => {
const runs: StyleRun[] = [{ start: 0, length: 3, style: { fontWeight: 700 } }]
const adjusted = adjustRunsForInsert(runs, 10, 5)
expect(adjusted[0]).toEqual({ start: 0, length: 3, style: { fontWeight: 700 } })
})
})
describe('adjustRunsForDelete', () => {
test('delete before run shifts start', () => {
const runs: StyleRun[] = [{ start: 10, length: 5, style: { fontWeight: 700 } }]
const adjusted = adjustRunsForDelete(runs, 2, 3)
expect(adjusted[0].start).toBe(7)
expect(adjusted[0].length).toBe(5)
})
test('delete inside run shrinks length', () => {
const runs: StyleRun[] = [{ start: 0, length: 10, style: { fontWeight: 700 } }]
const adjusted = adjustRunsForDelete(runs, 3, 4)
expect(adjusted[0].length).toBe(6)
})
test('delete entire run removes it', () => {
const runs: StyleRun[] = [{ start: 2, length: 3, style: { fontWeight: 700 } }]
const adjusted = adjustRunsForDelete(runs, 2, 3)
expect(adjusted).toHaveLength(0)
})
test('delete after run leaves it unchanged', () => {
const runs: StyleRun[] = [{ start: 0, length: 3, style: { fontWeight: 700 } }]
const adjusted = adjustRunsForDelete(runs, 5, 2)
expect(adjusted[0]).toEqual({ start: 0, length: 3, style: { fontWeight: 700 } })
})
})
describe('toggleBoldInRange', () => {
test('non-bold to bold', () => {
const { runs, newWeight } = toggleBoldInRange([], 0, 5, 400, 10)
expect(newWeight).toBe(700)
expect(runs).toHaveLength(1)
expect(runs[0].style.fontWeight).toBe(700)
})
test('bold to non-bold', () => {
const existing: StyleRun[] = [{ start: 0, length: 5, style: { fontWeight: 700 } }]
const { runs, newWeight } = toggleBoldInRange(existing, 0, 5, 400, 10)
expect(newWeight).toBe(400)
})
})
describe('toggleItalicInRange', () => {
test('toggle on', () => {
const { runs, newItalic } = toggleItalicInRange([], 0, 5, false, 10)
expect(newItalic).toBe(true)
expect(runs[0].style.italic).toBe(true)
})
test('toggle off', () => {
const existing: StyleRun[] = [{ start: 0, length: 5, style: { italic: true } }]
const { newItalic } = toggleItalicInRange(existing, 0, 5, false, 10)
expect(newItalic).toBe(false)
})
})
describe('toggleDecorationInRange', () => {
test('add underline', () => {
const { newDeco } = toggleDecorationInRange([], 0, 5, 'UNDERLINE', 'NONE', 10)
expect(newDeco).toBe('UNDERLINE')
})
test('remove underline', () => {
const existing: StyleRun[] = [{ start: 0, length: 5, style: { textDecoration: 'UNDERLINE' } }]
const { newDeco } = toggleDecorationInRange(existing, 0, 5, 'UNDERLINE', 'NONE', 10)
expect(newDeco).toBe('NONE')
})
})

View file

@ -0,0 +1,205 @@
import { describe, test, expect } from 'bun:test'
import { TextEditor, type SceneNode } from '@open-pencil/core'
const mockCk = {} as any
function createEditor(text = 'Hello World') {
const editor = new TextEditor(mockCk)
const node = { id: 'test-node', text } as SceneNode
editor.start(node)
return { editor, node }
}
describe('TextEditor', () => {
test('start initializes state', () => {
const { editor } = createEditor()
expect(editor.isActive).toBe(true)
expect(editor.nodeId).toBe('test-node')
expect(editor.state?.text).toBe('Hello World')
expect(editor.state?.cursor).toBe(11)
})
test('stop returns result and clears state', () => {
const { editor } = createEditor()
const result = editor.stop()
expect(result?.nodeId).toBe('test-node')
expect(result?.text).toBe('Hello World')
expect(editor.isActive).toBe(false)
expect(editor.nodeId).toBeNull()
})
test('stop on inactive returns null', () => {
const editor = new TextEditor(mockCk)
expect(editor.stop()).toBeNull()
})
test('insert at end', () => {
const { editor, node } = createEditor()
editor.insert('!', node)
expect(editor.state?.text).toBe('Hello World!')
expect(editor.state?.cursor).toBe(12)
})
test('insert at cursor position', () => {
const { editor, node } = createEditor()
editor.state!.cursor = 5
editor.insert(' Beautiful', node)
expect(editor.state?.text).toBe('Hello Beautiful World')
})
test('insert replaces selection', () => {
const { editor, node } = createEditor()
editor.state!.selectionAnchor = 0
editor.state!.cursor = 5
editor.insert('Goodbye', node)
expect(editor.state?.text).toBe('Goodbye World')
expect(editor.state?.cursor).toBe(7)
expect(editor.state?.selectionAnchor).toBeNull()
})
test('backspace deletes char before cursor', () => {
const { editor, node } = createEditor()
editor.backspace(node)
expect(editor.state?.text).toBe('Hello Worl')
expect(editor.state?.cursor).toBe(10)
})
test('backspace at start does nothing', () => {
const { editor, node } = createEditor()
editor.state!.cursor = 0
editor.backspace(node)
expect(editor.state?.text).toBe('Hello World')
})
test('backspace deletes selection', () => {
const { editor, node } = createEditor()
editor.state!.selectionAnchor = 6
editor.state!.cursor = 11
editor.backspace(node)
expect(editor.state?.text).toBe('Hello ')
expect(editor.state?.cursor).toBe(6)
})
test('delete removes char after cursor', () => {
const { editor, node } = createEditor()
editor.state!.cursor = 0
editor.delete(node)
expect(editor.state?.text).toBe('ello World')
expect(editor.state?.cursor).toBe(0)
})
test('delete at end does nothing', () => {
const { editor, node } = createEditor()
editor.delete(node)
expect(editor.state?.text).toBe('Hello World')
})
test('delete removes selection', () => {
const { editor, node } = createEditor()
editor.state!.selectionAnchor = 0
editor.state!.cursor = 5
editor.delete(node)
expect(editor.state?.text).toBe(' World')
})
test('moveLeft', () => {
const { editor } = createEditor()
editor.moveLeft()
expect(editor.state?.cursor).toBe(10)
})
test('moveRight at end stays', () => {
const { editor } = createEditor()
editor.moveRight()
expect(editor.state?.cursor).toBe(11)
})
test('moveLeft collapses selection to start', () => {
const { editor } = createEditor()
editor.state!.selectionAnchor = 3
editor.state!.cursor = 8
editor.moveLeft()
expect(editor.state?.cursor).toBe(3)
expect(editor.state?.selectionAnchor).toBeNull()
})
test('moveRight collapses selection to end', () => {
const { editor } = createEditor()
editor.state!.selectionAnchor = 3
editor.state!.cursor = 8
editor.moveRight()
expect(editor.state?.cursor).toBe(8)
expect(editor.state?.selectionAnchor).toBeNull()
})
test('selectAll', () => {
const { editor } = createEditor()
editor.selectAll()
expect(editor.state?.selectionAnchor).toBe(0)
expect(editor.state?.cursor).toBe(11)
})
test('hasSelection', () => {
const { editor } = createEditor()
expect(editor.hasSelection()).toBe(false)
editor.state!.selectionAnchor = 0
expect(editor.hasSelection()).toBe(true)
})
test('hasSelection false when anchor equals cursor', () => {
const { editor } = createEditor()
editor.state!.selectionAnchor = editor.state!.cursor
expect(editor.hasSelection()).toBe(false)
})
test('getSelectionRange', () => {
const { editor } = createEditor()
expect(editor.getSelectionRange()).toBeNull()
editor.state!.selectionAnchor = 8
editor.state!.cursor = 3
expect(editor.getSelectionRange()).toEqual([3, 8])
})
test('getSelectedText', () => {
const { editor } = createEditor()
editor.state!.selectionAnchor = 0
editor.state!.cursor = 5
expect(editor.getSelectedText()).toBe('Hello')
})
test('selectWord', () => {
const { editor } = createEditor()
editor.selectWord(2)
expect(editor.state?.selectionAnchor).toBe(0)
expect(editor.state?.cursor).toBe(5)
})
test('selectWord on second word', () => {
const { editor } = createEditor()
editor.selectWord(8)
expect(editor.state?.selectionAnchor).toBe(6)
expect(editor.state?.cursor).toBe(11)
})
test('moveWordLeft', () => {
const { editor } = createEditor()
editor.moveWordLeft()
expect(editor.state?.cursor).toBe(6)
})
test('moveWordRight from start', () => {
const { editor } = createEditor()
editor.state!.cursor = 0
editor.moveWordRight()
expect(editor.state?.cursor).toBe(6)
})
test('moveLeft with extend creates selection', () => {
const { editor } = createEditor()
editor.state!.cursor = 5
editor.moveLeft(true)
expect(editor.state?.selectionAnchor).toBe(5)
expect(editor.state?.cursor).toBe(4)
})
})

134
tests/engine/undo.test.ts Normal file
View file

@ -0,0 +1,134 @@
import { describe, test, expect } from 'bun:test'
import { UndoManager } from '@open-pencil/core'
describe('UndoManager', () => {
test('initial state', () => {
const undo = new UndoManager()
expect(undo.canUndo).toBe(false)
expect(undo.canRedo).toBe(false)
expect(undo.undoLabel).toBeNull()
expect(undo.redoLabel).toBeNull()
})
test('apply executes forward and enables undo', () => {
const undo = new UndoManager()
let value = 0
undo.apply({
label: 'increment',
forward: () => { value = 1 },
inverse: () => { value = 0 }
})
expect(value).toBe(1)
expect(undo.canUndo).toBe(true)
expect(undo.undoLabel).toBe('increment')
})
test('push does not execute forward', () => {
const undo = new UndoManager()
let value = 0
undo.push({
label: 'set',
forward: () => { value = 1 },
inverse: () => { value = 0 }
})
expect(value).toBe(0)
expect(undo.canUndo).toBe(true)
})
test('undo calls inverse and returns label', () => {
const undo = new UndoManager()
let value = 0
undo.apply({
label: 'set',
forward: () => { value = 1 },
inverse: () => { value = 0 }
})
const label = undo.undo()
expect(label).toBe('set')
expect(value).toBe(0)
expect(undo.canUndo).toBe(false)
expect(undo.canRedo).toBe(true)
})
test('redo calls forward and returns label', () => {
const undo = new UndoManager()
let value = 0
undo.apply({
label: 'set',
forward: () => { value = 1 },
inverse: () => { value = 0 }
})
undo.undo()
const label = undo.redo()
expect(label).toBe('set')
expect(value).toBe(1)
expect(undo.canRedo).toBe(false)
})
test('new action clears redo stack', () => {
const undo = new UndoManager()
undo.apply({ label: 'a', forward: () => {}, inverse: () => {} })
undo.undo()
expect(undo.canRedo).toBe(true)
undo.apply({ label: 'b', forward: () => {}, inverse: () => {} })
expect(undo.canRedo).toBe(false)
})
test('undo on empty returns null', () => {
const undo = new UndoManager()
expect(undo.undo()).toBeNull()
})
test('redo on empty returns null', () => {
const undo = new UndoManager()
expect(undo.redo()).toBeNull()
})
test('clear empties both stacks', () => {
const undo = new UndoManager()
undo.apply({ label: 'a', forward: () => {}, inverse: () => {} })
undo.undo()
undo.clear()
expect(undo.canUndo).toBe(false)
expect(undo.canRedo).toBe(false)
})
test('batch combines into single entry', () => {
const undo = new UndoManager()
const log: number[] = []
undo.beginBatch('batch')
undo.apply({ label: 'a', forward: () => log.push(1), inverse: () => log.push(-1) })
undo.apply({ label: 'b', forward: () => log.push(2), inverse: () => log.push(-2) })
undo.commitBatch()
expect(log).toEqual([1, 2])
expect(undo.undoLabel).toBe('batch')
undo.undo()
expect(log).toEqual([1, 2, -2, -1])
})
test('empty batch commits nothing', () => {
const undo = new UndoManager()
undo.beginBatch('empty')
undo.commitBatch()
expect(undo.canUndo).toBe(false)
})
test('multiple undo/redo', () => {
const undo = new UndoManager()
let val = 0
undo.apply({ label: 'to1', forward: () => { val = 1 }, inverse: () => { val = 0 } })
undo.apply({ label: 'to2', forward: () => { val = 2 }, inverse: () => { val = 1 } })
undo.apply({ label: 'to3', forward: () => { val = 3 }, inverse: () => { val = 2 } })
undo.undo()
expect(val).toBe(2)
undo.undo()
expect(val).toBe(1)
undo.redo()
expect(val).toBe(2)
undo.redo()
expect(val).toBe(3)
})
})

175
tests/engine/vector.test.ts Normal file
View file

@ -0,0 +1,175 @@
import { describe, test, expect } from 'bun:test'
import {
encodeVectorNetworkBlob,
decodeVectorNetworkBlob,
computeVectorBounds,
type VectorNetwork,
} from '@open-pencil/core'
describe('vectorNetworkBlob round-trip', () => {
test('empty network', () => {
const network: VectorNetwork = { vertices: [], segments: [], regions: [] }
const blob = encodeVectorNetworkBlob(network)
const decoded = decodeVectorNetworkBlob(blob)
expect(decoded.vertices).toHaveLength(0)
expect(decoded.segments).toHaveLength(0)
expect(decoded.regions).toHaveLength(0)
})
test('single line segment', () => {
const network: VectorNetwork = {
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' },
{ x: 100, y: 50, handleMirroring: 'NONE' }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: []
}
const blob = encodeVectorNetworkBlob(network)
const decoded = decodeVectorNetworkBlob(blob)
expect(decoded.vertices).toHaveLength(2)
expect(decoded.vertices[0].x).toBeCloseTo(0)
expect(decoded.vertices[1].x).toBeCloseTo(100)
expect(decoded.vertices[1].y).toBeCloseTo(50)
expect(decoded.segments).toHaveLength(1)
expect(decoded.segments[0].start).toBe(0)
expect(decoded.segments[0].end).toBe(1)
})
test('cubic bezier segment', () => {
const network: VectorNetwork = {
vertices: [
{ x: 0, y: 0, handleMirroring: 'ANGLE' },
{ x: 100, y: 100, handleMirroring: 'ANGLE' }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 30, y: 0 }, tangentEnd: { x: -30, y: 0 } }
],
regions: []
}
const blob = encodeVectorNetworkBlob(network)
const decoded = decodeVectorNetworkBlob(blob)
expect(decoded.segments[0].tangentStart.x).toBeCloseTo(30)
expect(decoded.segments[0].tangentEnd.x).toBeCloseTo(-30)
})
test('network with region', () => {
const network: VectorNetwork = {
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' },
{ x: 100, y: 0, handleMirroring: 'NONE' },
{ x: 50, y: 100, handleMirroring: 'NONE' }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [
{ windingRule: 'NONZERO', loops: [[0, 1, 2]] }
]
}
const blob = encodeVectorNetworkBlob(network)
const decoded = decodeVectorNetworkBlob(blob)
expect(decoded.regions).toHaveLength(1)
expect(decoded.regions[0].windingRule).toBe('NONZERO')
expect(decoded.regions[0].loops).toEqual([[0, 1, 2]])
})
test('region with EVENODD winding', () => {
const network: VectorNetwork = {
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' },
{ x: 10, y: 0, handleMirroring: 'NONE' },
{ x: 10, y: 10, handleMirroring: 'NONE' },
{ x: 0, y: 10, handleMirroring: 'NONE' }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 3, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 3, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [
{ windingRule: 'EVENODD', loops: [[0, 1, 2, 3]] }
]
}
const blob = encodeVectorNetworkBlob(network)
const decoded = decodeVectorNetworkBlob(blob)
expect(decoded.regions[0].windingRule).toBe('EVENODD')
})
test('multiple regions with multiple loops', () => {
const network: VectorNetwork = {
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' },
{ x: 10, y: 0, handleMirroring: 'NONE' },
{ x: 10, y: 10, handleMirroring: 'NONE' }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 1, end: 2, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } },
{ start: 2, end: 0, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }
],
regions: [
{ windingRule: 'NONZERO', loops: [[0, 1], [2]] },
{ windingRule: 'EVENODD', loops: [[0, 2]] }
]
}
const blob = encodeVectorNetworkBlob(network)
const decoded = decodeVectorNetworkBlob(blob)
expect(decoded.regions).toHaveLength(2)
expect(decoded.regions[0].loops).toEqual([[0, 1], [2]])
expect(decoded.regions[1].loops).toEqual([[0, 2]])
})
})
describe('computeVectorBounds', () => {
test('empty network', () => {
expect(computeVectorBounds({ vertices: [], segments: [], regions: [] })).toEqual({
x: 0, y: 0, width: 0, height: 0
})
})
test('single vertex', () => {
const bounds = computeVectorBounds({
vertices: [{ x: 50, y: 30, handleMirroring: 'NONE' }],
segments: [],
regions: []
})
expect(bounds.x).toBe(50)
expect(bounds.y).toBe(30)
expect(bounds.width).toBe(0)
expect(bounds.height).toBe(0)
})
test('two vertices', () => {
const bounds = computeVectorBounds({
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' },
{ x: 100, y: 50, handleMirroring: 'NONE' }
],
segments: [{ start: 0, end: 1, tangentStart: { x: 0, y: 0 }, tangentEnd: { x: 0, y: 0 } }],
regions: []
})
expect(bounds).toEqual({ x: 0, y: 0, width: 100, height: 50 })
})
test('bezier control points extend bounds', () => {
const bounds = computeVectorBounds({
vertices: [
{ x: 0, y: 0, handleMirroring: 'NONE' },
{ x: 100, y: 0, handleMirroring: 'NONE' }
],
segments: [
{ start: 0, end: 1, tangentStart: { x: 0, y: -50 }, tangentEnd: { x: 0, y: 50 } }
],
regions: []
})
expect(bounds.y).toBe(-50)
expect(bounds.height).toBe(100)
})
})