diff --git a/oxlint.json b/oxlint.json index d7e9cf65a..f52977a54 100644 --- a/oxlint.json +++ b/oxlint.json @@ -215,7 +215,6 @@ { "files": [ "tests/engine/editor/clipboard/import-nodes.test.ts", - "tests/engine/figma/api.test.ts", "tests/engine/layout/auto-layout.test.ts", "tests/engine/scene-graph/basic.test.ts" ], diff --git a/tests/engine/figma/api.test.ts b/tests/engine/figma/api.test.ts deleted file mode 100644 index 67cb50547..000000000 --- a/tests/engine/figma/api.test.ts +++ /dev/null @@ -1,991 +0,0 @@ -import { describe, expect, test } from 'bun:test' - -import { FigmaAPI, SceneGraph, type Fill } from '@open-pencil/core' - -import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert' - -function createAPI(): FigmaAPI { - return new FigmaAPI(new SceneGraph()) -} - -describe('FigmaAPI', () => { - describe('document & pages', () => { - test('root has pages as children', () => { - const api = createAPI() - expect(api.root.children.length).toBeGreaterThan(0) - expect(api.root.children[0].type).toBe('CANVAS') - }) - - test('currentPage is first page', () => { - const api = createAPI() - expect(api.currentPage.type).toBe('CANVAS') - expect(api.currentPage.name).toBe('Page 1') - }) - - test('createPage adds a new page', () => { - const api = createAPI() - const page = api.createPage() - expect(page.type).toBe('CANVAS') - expect(api.root.children.length).toBe(2) - }) - - test('currentPage can be switched', () => { - const api = createAPI() - const page2 = api.createPage() - page2.name = 'Page 2' - api.currentPage = page2 - expect(api.currentPage.name).toBe('Page 2') - }) - - test('getNodeById returns proxy', () => { - const api = createAPI() - const frame = api.createFrame() - const found = api.getNodeById(frame.id) - expect(expectDefined(found, 'found frame').id).toBe(frame.id) - }) - - test('getNodeById returns null for unknown id', () => { - const api = createAPI() - expect(api.getNodeById('nonexistent')).toBeNull() - }) - }) - - describe('node creation', () => { - test('createFrame', () => { - const api = createAPI() - const frame = api.createFrame() - expect(frame.type).toBe('FRAME') - expect(expectDefined(frame.parent, 'frame parent').id).toBe(api.currentPage.id) - }) - - test('createRectangle', () => { - const api = createAPI() - const rect = api.createRectangle() - expect(rect.type).toBe('RECTANGLE') - }) - - test('createEllipse', () => { - const api = createAPI() - expect(api.createEllipse().type).toBe('ELLIPSE') - }) - - test('createText', () => { - const api = createAPI() - expect(api.createText().type).toBe('TEXT') - }) - - test('createLine', () => { - const api = createAPI() - expect(api.createLine().type).toBe('LINE') - }) - - test('createPolygon', () => { - const api = createAPI() - expect(api.createPolygon().type).toBe('POLYGON') - }) - - test('createStar', () => { - const api = createAPI() - expect(api.createStar().type).toBe('STAR') - }) - - test('createVector', () => { - const api = createAPI() - expect(api.createVector().type).toBe('VECTOR') - }) - - test('createComponent', () => { - const api = createAPI() - expect(api.createComponent().type).toBe('COMPONENT') - }) - - test('createSection', () => { - const api = createAPI() - expect(api.createSection().type).toBe('SECTION') - }) - - test('created node is child of current page', () => { - const api = createAPI() - const frame = api.createFrame() - expect(api.currentPage.children.some((c) => c.id === frame.id)).toBe(true) - }) - }) - - describe('property access', () => { - test('name get/set', () => { - const api = createAPI() - const frame = api.createFrame() - frame.name = 'Card' - expect(frame.name).toBe('Card') - }) - - test('position get/set', () => { - const api = createAPI() - const frame = api.createFrame() - frame.x = 50 - frame.y = 100 - expect(frame.x).toBe(50) - expect(frame.y).toBe(100) - }) - - test('resize', () => { - const api = createAPI() - const frame = api.createFrame() - frame.resize(300, 200) - expect(frame.width).toBe(300) - expect(frame.height).toBe(200) - }) - - test('fills get/set', () => { - const api = createAPI() - const rect = api.createRectangle() - rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] - expect(rect.fills.length).toBe(1) - expect(rect.fills[0].color.r).toBe(1) - }) - - test('opacity get/set', () => { - const api = createAPI() - const frame = api.createFrame() - frame.opacity = 0.5 - expect(frame.opacity).toBe(0.5) - }) - - test('visible get/set', () => { - const api = createAPI() - const frame = api.createFrame() - frame.visible = false - expect(frame.visible).toBe(false) - }) - - test('locked get/set', () => { - const api = createAPI() - const frame = api.createFrame() - frame.locked = true - expect(frame.locked).toBe(true) - }) - - test('rotation get/set', () => { - const api = createAPI() - const frame = api.createFrame() - frame.rotation = 45 - expect(frame.rotation).toBe(45) - }) - - test('clipsContent get/set', () => { - const api = createAPI() - const frame = api.createFrame() - frame.clipsContent = true - expect(frame.clipsContent).toBe(true) - }) - }) - - describe('corner radius', () => { - test('uniform corner radius', () => { - const api = createAPI() - const rect = api.createRectangle() - rect.cornerRadius = 8 - expect(rect.cornerRadius).toBe(8) - expect(rect.topLeftRadius).toBe(8) - expect(rect.bottomRightRadius).toBe(8) - }) - - test('independent corners return mixed', () => { - const api = createAPI() - const rect = api.createRectangle() - rect.topLeftRadius = 4 - rect.bottomRightRadius = 12 - expect(rect.cornerRadius).toBe(api.mixed) - }) - }) - - describe('text', () => { - test('characters maps to text content', () => { - const api = createAPI() - const text = api.createText() - text.characters = 'Hello World' - expect(text.characters).toBe('Hello World') - }) - - test('fontSize get/set', () => { - const api = createAPI() - const text = api.createText() - text.fontSize = 24 - expect(text.fontSize).toBe(24) - }) - - test('fontName get/set', () => { - const api = createAPI() - const text = api.createText() - text.fontName = { family: 'Roboto', style: 'Bold' } - expect(text.fontName.family).toBe('Roboto') - expect(text.fontWeight).toBe(700) - }) - - test('fontName italic', () => { - const api = createAPI() - const text = api.createText() - text.fontName = { family: 'Inter', style: 'Bold Italic' } - expect(text.fontName.family).toBe('Inter') - expect(text.fontWeight).toBe(700) - expect(text.fontName.style).toBe('Bold Italic') - }) - - test('textAlignHorizontal', () => { - const api = createAPI() - const text = api.createText() - text.textAlignHorizontal = 'CENTER' - expect(text.textAlignHorizontal).toBe('CENTER') - }) - - test('textDirection', () => { - const api = createAPI() - const text = api.createText() - text.textDirection = 'RTL' - expect(text.textDirection).toBe('RTL') - }) - - test('textAutoResize', () => { - const api = createAPI() - const text = api.createText() - text.textAutoResize = 'WIDTH_AND_HEIGHT' - expect(text.textAutoResize).toBe('WIDTH_AND_HEIGHT') - }) - }) - - describe('auto-layout', () => { - test('layoutMode', () => { - const api = createAPI() - const frame = api.createFrame() - frame.layoutMode = 'VERTICAL' - expect(frame.layoutMode).toBe('VERTICAL') - }) - - test('layoutDirection', () => { - const api = createAPI() - const frame = api.createFrame() - expect(frame.layoutDirection).toBe('AUTO') - frame.layoutDirection = 'RTL' - expect(frame.layoutDirection).toBe('RTL') - }) - - test('layoutDirection falls back to AUTO for legacy nodes with no stored value', () => { - const api = createAPI() - const frame = api.createFrame() - const raw = api.graph.getNode(frame.id) - expect(raw).toBeDefined() - if (!raw) return - Reflect.deleteProperty(raw as object, 'layoutDirection') - expect(frame.layoutDirection).toBe('AUTO') - }) - - test('itemSpacing', () => { - const api = createAPI() - const frame = api.createFrame() - frame.layoutMode = 'VERTICAL' - frame.itemSpacing = 12 - expect(frame.itemSpacing).toBe(12) - }) - - test('padding', () => { - const api = createAPI() - const frame = api.createFrame() - frame.paddingTop = 16 - frame.paddingRight = 24 - frame.paddingBottom = 16 - frame.paddingLeft = 24 - expect(frame.paddingTop).toBe(16) - expect(frame.paddingRight).toBe(24) - }) - - test('alignment', () => { - const api = createAPI() - const frame = api.createFrame() - frame.layoutMode = 'HORIZONTAL' - frame.primaryAxisAlignItems = 'SPACE_BETWEEN' - frame.counterAxisAlignItems = 'CENTER' - expect(frame.primaryAxisAlignItems).toBe('SPACE_BETWEEN') - expect(frame.counterAxisAlignItems).toBe('CENTER') - }) - - test('layoutWrap', () => { - const api = createAPI() - const frame = api.createFrame() - frame.layoutWrap = 'WRAP' - expect(frame.layoutWrap).toBe('WRAP') - }) - }) - - describe('tree operations', () => { - test('appendChild reparents', () => { - const api = createAPI() - const parent = api.createFrame() - const child = api.createRectangle() - parent.appendChild(child) - expect(parent.children.length).toBe(1) - expect(parent.children[0].id).toBe(child.id) - expect(expectDefined(child.parent, 'child parent').id).toBe(parent.id) - }) - - test('insertChild at index', () => { - const api = createAPI() - const parent = api.createFrame() - const a = api.createRectangle() - const b = api.createRectangle() - const c = api.createRectangle() - parent.appendChild(a) - parent.appendChild(b) - parent.insertChild(1, c) - expect(parent.children.map((c) => c.id)).toEqual([a.id, c.id, b.id]) - }) - - test('remove deletes node', () => { - const api = createAPI() - const frame = api.createFrame() - const id = frame.id - frame.remove() - expect(api.getNodeById(id)).toBeNull() - }) - - test('removed property', () => { - const api = createAPI() - const frame = api.createFrame() - expect(frame.removed).toBe(false) - frame.remove() - expect(frame.removed).toBe(true) - }) - }) - - describe('traversal', () => { - test('findAll finds deep descendants', () => { - const api = createAPI() - const parent = api.createFrame() - parent.name = 'Container' - const child = api.createFrame() - child.name = 'Card' - parent.appendChild(child) - const text = api.createText() - text.characters = 'Title' - text.name = 'Title' - child.appendChild(text) - - const found = parent.findAll((n) => n.type === 'TEXT') - expect(found.length).toBe(1) - expect(found[0].characters).toBe('Title') - }) - - test('findAll without callback returns all', () => { - const api = createAPI() - const parent = api.createFrame() - const a = api.createRectangle() - const b = api.createText() - parent.appendChild(a) - parent.appendChild(b) - expect(parent.findAll().length).toBe(2) - }) - - test('findOne returns first match', () => { - const api = createAPI() - const parent = api.createFrame() - const a = api.createRectangle() - a.name = 'First' - const b = api.createRectangle() - b.name = 'Second' - parent.appendChild(a) - parent.appendChild(b) - const found = parent.findOne((n) => n.type === 'RECTANGLE') - expect(expectDefined(found, 'first rectangle').name).toBe('First') - }) - - test('findChild searches direct children only', () => { - const api = createAPI() - const parent = api.createFrame() - const child = api.createFrame() - const deep = api.createText() - deep.name = 'Deep' - parent.appendChild(child) - child.appendChild(deep) - - expect(parent.findChild((n) => n.name === 'Deep')).toBeNull() - expect(child.findChild((n) => n.name === 'Deep')).not.toBeNull() - }) - - test('findChildren returns direct children matching', () => { - const api = createAPI() - const parent = api.createFrame() - const a = api.createRectangle() - const b = api.createText() - const c = api.createRectangle() - parent.appendChild(a) - parent.appendChild(b) - parent.appendChild(c) - const rects = parent.findChildren((n) => n.type === 'RECTANGLE') - expect(rects.length).toBe(2) - }) - }) - - describe('grouping', () => { - test('group creates GROUP with children', () => { - const api = createAPI() - const a = api.createRectangle() - const b = api.createEllipse() - const group = api.group([a, b], api.currentPage) - expect(group.type).toBe('GROUP') - expect(group.children.length).toBe(2) - }) - - test('ungroup dissolves group', () => { - const api = createAPI() - const a = api.createRectangle() - const b = api.createEllipse() - const group = api.group([a, b], api.currentPage) - const groupId = group.id - api.ungroup(group) - expect(api.getNodeById(groupId)).toBeNull() - expect(expectDefined(a.parent, 'ungrouped parent').id).toBe(api.currentPage.id) - }) - }) - - describe('selection', () => { - test('selection get/set', () => { - const api = createAPI() - const frame = api.createFrame() - api.currentPage.selection = [frame] - expect(api.currentPage.selection.length).toBe(1) - expect(api.currentPage.selection[0].id).toBe(frame.id) - }) - - test('selection empty by default', () => { - const api = createAPI() - expect(api.currentPage.selection).toEqual([]) - }) - }) - - describe('components', () => { - test('createInstance from component', () => { - const api = createAPI() - const comp = api.createComponent() - comp.name = 'Button' - comp.resize(200, 40) - const instance = comp.createInstance() - expect(instance.type).toBe('INSTANCE') - expect(expectDefined(instance.mainComponent, 'instance main component').id).toBe(comp.id) - }) - }) - - describe('serialization', () => { - test('toJSON returns clean object', () => { - const api = createAPI() - const frame = api.createFrame() - frame.name = 'Card' - frame.resize(300, 200) - frame.fills = [ - { type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true } - ] - const json = frame.toJSON() - expect(json.name).toBe('Card') - expect(json.width).toBe(300) - expect(json.fills).toBeDefined() - }) - - test('toJSON includes children', () => { - const api = createAPI() - const frame = api.createFrame() - const rect = api.createRectangle() - frame.appendChild(rect) - const json = frame.toJSON() as { children?: unknown[] } - expect(json.children).toBeDefined() - expect(json.children?.length).toBe(1) - }) - - test('toString returns readable string', () => { - const api = createAPI() - const frame = api.createFrame() - frame.name = 'Card' - expect(frame.toString()).toContain('FRAME') - expect(frame.toString()).toContain('Card') - }) - }) - - describe('layout sizing', () => { - test('child in horizontal parent: h=primary, v=counter', () => { - const api = createAPI() - const parent = api.createFrame() - parent.layoutMode = 'HORIZONTAL' - const child = api.createFrame() - parent.appendChild(child) - child.layoutSizingHorizontal = 'FILL' - child.layoutSizingVertical = 'HUG' - expect(child.layoutSizingHorizontal).toBe('FILL') - expect(child.layoutSizingVertical).toBe('HUG') - }) - - test('child in vertical parent: h=counter, v=primary', () => { - const api = createAPI() - const parent = api.createFrame() - parent.layoutMode = 'VERTICAL' - const child = api.createFrame() - parent.appendChild(child) - child.layoutSizingHorizontal = 'FILL' - child.layoutSizingVertical = 'HUG' - expect(child.layoutSizingHorizontal).toBe('FILL') - expect(child.layoutSizingVertical).toBe('HUG') - }) - - test('auto-layout frame that is also a child of auto-layout', () => { - const api = createAPI() - const outer = api.createFrame() - outer.layoutMode = 'HORIZONTAL' - const inner = api.createFrame() - inner.layoutMode = 'VERTICAL' - outer.appendChild(inner) - inner.layoutSizingHorizontal = 'FILL' - expect(inner.layoutSizingHorizontal).toBe('FILL') - expect(inner.layoutMode).toBe('VERTICAL') - }) - - test('HORIZONTAL child in VERTICAL parent: sizing maps to correct raw axis', () => { - const api = createAPI() - const parent = api.createFrame() - parent.layoutMode = 'VERTICAL' - parent.resize(375, 812) - const child = api.createFrame() - child.layoutMode = 'HORIZONTAL' - parent.appendChild(child) - child.layoutSizingVertical = 'FIXED' - child.resize(375, 44) - expect(child.layoutSizingVertical).toBe('FIXED') - const raw = getNodeOrThrow(api.graph, child.id) - expect(raw.counterAxisSizing).toBe('FIXED') - }) - - test('VERTICAL child in HORIZONTAL parent: sizing maps to correct raw axis', () => { - const api = createAPI() - const parent = api.createFrame() - parent.layoutMode = 'HORIZONTAL' - parent.resize(800, 600) - const child = api.createFrame() - child.layoutMode = 'VERTICAL' - parent.appendChild(child) - child.layoutSizingHorizontal = 'FIXED' - child.resize(200, 600) - expect(child.layoutSizingHorizontal).toBe('FIXED') - const raw = getNodeOrThrow(api.graph, child.id) - expect(raw.counterAxisSizing).toBe('FIXED') - }) - }) - - describe('frozen arrays', () => { - test('fills returns frozen clone', () => { - const api = createAPI() - const rect = api.createRectangle() - rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] - const fills = rect.fills - expect(Object.isFrozen(fills)).toBe(true) - expect(() => { - ;(fills as Fill[]).push({} as Fill) - }).toThrow() - }) - - test('mutating returned fills does not affect node', () => { - const api = createAPI() - const rect = api.createRectangle() - rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] - const fills = rect.fills as Fill[] - try { - fills[0].color.r = 0 - } catch (error) { - expect(error).toBeInstanceOf(TypeError) - } - expect(rect.fills[0].color.r).toBe(1) - }) - }) - - describe('internals not exposed', () => { - test('node has no _id, _graph, _api properties', () => { - const api = createAPI() - const frame = api.createFrame() - expect('_id' in frame).toBe(false) - expect('_graph' in frame).toBe(false) - expect('_api' in frame).toBe(false) - }) - }) - - describe('stubs', () => { - test('loadFontAsync resolves', async () => { - const api = createAPI() - await api.loadFontAsync({ family: 'Inter', style: 'Regular' }) - }) - - test('mixed is a symbol', () => { - const api = createAPI() - expect(typeof api.mixed).toBe('symbol') - }) - }) - - describe('absolute position', () => { - test('absoluteBoundingBox accounts for nesting', () => { - const api = createAPI() - const parent = api.createFrame() - parent.x = 100 - parent.y = 200 - const child = api.createRectangle() - parent.appendChild(child) - child.x = 10 - child.y = 20 - child.resize(50, 30) - const bounds = child.absoluteBoundingBox - expect(bounds.x).toBe(110) - expect(bounds.y).toBe(220) - expect(bounds.width).toBe(50) - expect(bounds.height).toBe(30) - }) - }) - - describe('stroke details', () => { - test('strokeWeight and strokeAlign', () => { - const api = createAPI() - const rect = api.createRectangle() - rect.strokes = [ - { color: { r: 0, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'CENTER' } - ] - expect(rect.strokeWeight).toBe(2) - expect(rect.strokeAlign).toBe('CENTER') - rect.strokeWeight = 4 - expect(rect.strokeWeight).toBe(4) - }) - - test('strokeCap and strokeJoin', () => { - const api = createAPI() - const line = api.createLine() - expect(line.strokeCap).toBe('NONE') - expect(line.strokeJoin).toBe('MITER') - line.strokeCap = 'ROUND' - line.strokeJoin = 'BEVEL' - expect(line.strokeCap).toBe('ROUND') - expect(line.strokeJoin).toBe('BEVEL') - }) - - test('strokeMiterLimit', () => { - const api = createAPI() - const rect = api.createRectangle() - expect(rect.strokeMiterLimit).toBe(4) - rect.strokeMiterLimit = 8 - expect(rect.strokeMiterLimit).toBe(8) - }) - - test('individual stroke weights', () => { - const api = createAPI() - const frame = api.createFrame() - frame.strokeTopWeight = 1 - frame.strokeRightWeight = 2 - frame.strokeBottomWeight = 3 - frame.strokeLeftWeight = 4 - expect(frame.strokeTopWeight).toBe(1) - expect(frame.strokeRightWeight).toBe(2) - expect(frame.strokeBottomWeight).toBe(3) - expect(frame.strokeLeftWeight).toBe(4) - }) - }) - - describe('clone', () => { - test('clone creates a deep copy', () => { - const api = createAPI() - const frame = api.createFrame() - frame.name = 'Original' - frame.fills = [ - { type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true } - ] - const child = api.createRectangle() - child.name = 'Child' - frame.appendChild(child) - - const cloned = frame.clone() - expect(cloned.id).not.toBe(frame.id) - expect(cloned.name).toBe('Original') - expect(cloned.fills[0].color.r).toBe(1) - expect(cloned.children.length).toBe(1) - expect(cloned.children[0].name).toBe('Child') - expect(cloned.children[0].id).not.toBe(child.id) - }) - }) - - describe('min/max dimensions', () => { - test('defaults to null', () => { - const api = createAPI() - const frame = api.createFrame() - expect(frame.minWidth).toBeNull() - expect(frame.maxWidth).toBeNull() - expect(frame.minHeight).toBeNull() - expect(frame.maxHeight).toBeNull() - }) - - test('can set and read', () => { - const api = createAPI() - const frame = api.createFrame() - frame.minWidth = 50 - frame.maxWidth = 400 - frame.minHeight = 30 - frame.maxHeight = 600 - expect(frame.minWidth).toBe(50) - expect(frame.maxWidth).toBe(400) - expect(frame.minHeight).toBe(30) - expect(frame.maxHeight).toBe(600) - }) - - test('can reset to null', () => { - const api = createAPI() - const frame = api.createFrame() - frame.minWidth = 50 - frame.minWidth = null - expect(frame.minWidth).toBeNull() - }) - }) - - describe('mask', () => { - test('isMask defaults to false', () => { - const api = createAPI() - const rect = api.createRectangle() - expect(rect.isMask).toBe(false) - expect(rect.maskType).toBe('ALPHA') - }) - - test('can set mask properties', () => { - const api = createAPI() - const rect = api.createRectangle() - rect.isMask = true - rect.maskType = 'LUMINANCE' - expect(rect.isMask).toBe(true) - expect(rect.maskType).toBe('LUMINANCE') - }) - }) - - describe('auto-layout extras', () => { - test('primaryAxisSizingMode maps FIXED/AUTO', () => { - const api = createAPI() - const frame = api.createFrame() - frame.layoutMode = 'VERTICAL' - expect(frame.primaryAxisSizingMode).toBe('FIXED') - frame.primaryAxisSizingMode = 'AUTO' - expect(frame.primaryAxisSizingMode).toBe('AUTO') - expect(frame.layoutSizingVertical).toBe('HUG') - }) - - test('counterAxisSizingMode maps FIXED/AUTO', () => { - const api = createAPI() - const frame = api.createFrame() - frame.layoutMode = 'VERTICAL' - frame.counterAxisSizingMode = 'AUTO' - expect(frame.counterAxisSizingMode).toBe('AUTO') - expect(frame.layoutSizingHorizontal).toBe('HUG') - }) - - test('counterAxisAlignContent', () => { - const api = createAPI() - const frame = api.createFrame() - expect(frame.counterAxisAlignContent).toBe('AUTO') - frame.counterAxisAlignContent = 'SPACE_BETWEEN' - expect(frame.counterAxisAlignContent).toBe('SPACE_BETWEEN') - }) - - test('itemReverseZIndex', () => { - const api = createAPI() - const frame = api.createFrame() - expect(frame.itemReverseZIndex).toBe(false) - frame.itemReverseZIndex = true - expect(frame.itemReverseZIndex).toBe(true) - }) - - test('strokesIncludedInLayout', () => { - const api = createAPI() - const frame = api.createFrame() - expect(frame.strokesIncludedInLayout).toBe(false) - frame.strokesIncludedInLayout = true - expect(frame.strokesIncludedInLayout).toBe(true) - }) - - test('layoutAlign maps to layoutAlignSelf', () => { - const api = createAPI() - const parent = api.createFrame() - parent.layoutMode = 'VERTICAL' - const child = api.createFrame() - parent.appendChild(child) - expect(child.layoutAlign).toBe('INHERIT') - child.layoutAlign = 'STRETCH' - expect(child.layoutAlign).toBe('STRETCH') - }) - }) - - describe('text extras', () => { - test('textTruncation', () => { - const api = createAPI() - const text = api.createText() - expect(text.textTruncation).toBe('DISABLED') - text.textTruncation = 'ENDING' - expect(text.textTruncation).toBe('ENDING') - }) - - test('autoRename', () => { - const api = createAPI() - const text = api.createText() - expect(text.autoRename).toBe(true) - text.autoRename = false - expect(text.autoRename).toBe(false) - }) - - test('insertCharacters', () => { - const api = createAPI() - const text = api.createText() - text.characters = 'Hello World' - text.insertCharacters(5, ' Beautiful') - expect(text.characters).toBe('Hello Beautiful World') - }) - - test('deleteCharacters', () => { - const api = createAPI() - const text = api.createText() - text.characters = 'Hello Beautiful World' - text.deleteCharacters(5, 15) - expect(text.characters).toBe('Hello World') - }) - }) - - describe('expanded', () => { - test('defaults to true', () => { - const api = createAPI() - const frame = api.createFrame() - expect(frame.expanded).toBe(true) - }) - - test('can collapse', () => { - const api = createAPI() - const frame = api.createFrame() - frame.expanded = false - expect(frame.expanded).toBe(false) - }) - }) - - describe('findAllWithCriteria', () => { - test('filters by type', () => { - const api = createAPI() - api.createFrame() - api.createRectangle() - api.createText() - api.createRectangle() - const rects = api.currentPage.findAllWithCriteria({ types: ['RECTANGLE'] }) - expect(rects.length).toBe(2) - expect(rects.every((n) => n.type === 'RECTANGLE')).toBe(true) - }) - - test('filters by multiple types', () => { - const api = createAPI() - api.createFrame() - api.createRectangle() - api.createText() - const result = api.currentPage.findAllWithCriteria({ types: ['FRAME', 'TEXT'] }) - expect(result.length).toBe(2) - }) - }) - - describe('createComponentFromNode', () => { - test('converts frame to component', () => { - const api = createAPI() - const frame = api.createFrame() - frame.name = 'MyButton' - frame.resize(200, 50) - const child = api.createRectangle() - child.name = 'Background' - frame.appendChild(child) - const frameId = frame.id - - const comp = api.createComponentFromNode(frame) - expect(comp.type).toBe('COMPONENT') - expect(comp.name).toBe('MyButton') - expect(comp.width).toBe(200) - expect(comp.height).toBe(50) - expect(comp.children.length).toBe(1) - expect(comp.children[0].name).toBe('Background') - expect(api.getNodeById(frameId)).toBeNull() - }) - }) - - describe('variables', () => { - test('getLocalVariables returns empty by default', () => { - const api = createAPI() - expect(api.getLocalVariables()).toEqual([]) - }) - - test('getLocalVariables returns added variables', () => { - const api = createAPI() - api.graph.addCollection({ - id: 'col1', - name: 'Colors', - modes: [{ modeId: 'mode1', name: 'Default' }], - defaultModeId: 'mode1', - variableIds: [] - }) - api.graph.addVariable({ - id: 'var1', - name: 'primary', - type: 'COLOR', - collectionId: 'col1', - valuesByMode: { mode1: { r: 1, g: 0, b: 0, a: 1 } }, - description: '', - hiddenFromPublishing: false - }) - expect(api.getLocalVariables().length).toBe(1) - expect(api.getLocalVariables('COLOR').length).toBe(1) - expect(api.getLocalVariables('FLOAT').length).toBe(0) - }) - - test('getVariableById', () => { - const api = createAPI() - api.graph.addCollection({ - id: 'col1', - name: 'Spacing', - modes: [{ modeId: 'mode1', name: 'Default' }], - defaultModeId: 'mode1', - variableIds: [] - }) - api.graph.addVariable({ - id: 'var1', - name: 'spacing-sm', - type: 'FLOAT', - collectionId: 'col1', - valuesByMode: { mode1: 8 }, - description: '', - hiddenFromPublishing: false - }) - const v = api.getVariableById('var1') - expect(expectDefined(v, 'spacing variable').name).toBe('spacing-sm') - expect(api.getVariableById('nonexistent')).toBeNull() - }) - - test('getLocalVariableCollections', () => { - const api = createAPI() - api.graph.addCollection({ - id: 'col1', - name: 'Colors', - modes: [{ modeId: 'mode1', name: 'Default' }], - defaultModeId: 'mode1', - variableIds: [] - }) - const cols = api.getLocalVariableCollections() - expect(cols.length).toBe(1) - expect(cols[0].name).toBe('Colors') - }) - - test('getVariableCollectionById', () => { - const api = createAPI() - api.graph.addCollection({ - id: 'col1', - name: 'Colors', - modes: [{ modeId: 'mode1', name: 'Default' }], - defaultModeId: 'mode1', - variableIds: [] - }) - expect(api.getVariableCollectionById('col1')?.name).toBe('Colors') - expect(api.getVariableCollectionById('nonexistent')).toBeNull() - }) - }) -}) diff --git a/tests/engine/figma/api/absolute-position.test.ts b/tests/engine/figma/api/absolute-position.test.ts new file mode 100644 index 000000000..28e19dcbd --- /dev/null +++ b/tests/engine/figma/api/absolute-position.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('absolute position', () => { + test('absoluteBoundingBox accounts for nesting', () => { + const api = createAPI() + const parent = api.createFrame() + parent.x = 100 + parent.y = 200 + const child = api.createRectangle() + parent.appendChild(child) + child.x = 10 + child.y = 20 + child.resize(50, 30) + const bounds = child.absoluteBoundingBox + expect(bounds.x).toBe(110) + expect(bounds.y).toBe(220) + expect(bounds.width).toBe(50) + expect(bounds.height).toBe(30) + }) +}) diff --git a/tests/engine/figma/api/auto-layout-extras.test.ts b/tests/engine/figma/api/auto-layout-extras.test.ts new file mode 100644 index 000000000..63d755430 --- /dev/null +++ b/tests/engine/figma/api/auto-layout-extras.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('auto-layout extras', () => { + test('primaryAxisSizingMode maps FIXED/AUTO', () => { + const api = createAPI() + const frame = api.createFrame() + frame.layoutMode = 'VERTICAL' + expect(frame.primaryAxisSizingMode).toBe('FIXED') + frame.primaryAxisSizingMode = 'AUTO' + expect(frame.primaryAxisSizingMode).toBe('AUTO') + expect(frame.layoutSizingVertical).toBe('HUG') + }) + + test('counterAxisSizingMode maps FIXED/AUTO', () => { + const api = createAPI() + const frame = api.createFrame() + frame.layoutMode = 'VERTICAL' + frame.counterAxisSizingMode = 'AUTO' + expect(frame.counterAxisSizingMode).toBe('AUTO') + expect(frame.layoutSizingHorizontal).toBe('HUG') + }) + + test('counterAxisAlignContent', () => { + const api = createAPI() + const frame = api.createFrame() + expect(frame.counterAxisAlignContent).toBe('AUTO') + frame.counterAxisAlignContent = 'SPACE_BETWEEN' + expect(frame.counterAxisAlignContent).toBe('SPACE_BETWEEN') + }) + + test('itemReverseZIndex', () => { + const api = createAPI() + const frame = api.createFrame() + expect(frame.itemReverseZIndex).toBe(false) + frame.itemReverseZIndex = true + expect(frame.itemReverseZIndex).toBe(true) + }) + + test('strokesIncludedInLayout', () => { + const api = createAPI() + const frame = api.createFrame() + expect(frame.strokesIncludedInLayout).toBe(false) + frame.strokesIncludedInLayout = true + expect(frame.strokesIncludedInLayout).toBe(true) + }) + + test('layoutAlign maps to layoutAlignSelf', () => { + const api = createAPI() + const parent = api.createFrame() + parent.layoutMode = 'VERTICAL' + const child = api.createFrame() + parent.appendChild(child) + expect(child.layoutAlign).toBe('INHERIT') + child.layoutAlign = 'STRETCH' + expect(child.layoutAlign).toBe('STRETCH') + }) +}) diff --git a/tests/engine/figma/api/auto-layout.test.ts b/tests/engine/figma/api/auto-layout.test.ts new file mode 100644 index 000000000..ceba3f060 --- /dev/null +++ b/tests/engine/figma/api/auto-layout.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('auto-layout', () => { + test('layoutMode', () => { + const api = createAPI() + const frame = api.createFrame() + frame.layoutMode = 'VERTICAL' + expect(frame.layoutMode).toBe('VERTICAL') + }) + + test('layoutDirection', () => { + const api = createAPI() + const frame = api.createFrame() + expect(frame.layoutDirection).toBe('AUTO') + frame.layoutDirection = 'RTL' + expect(frame.layoutDirection).toBe('RTL') + }) + + test('layoutDirection falls back to AUTO for legacy nodes with no stored value', () => { + const api = createAPI() + const frame = api.createFrame() + const raw = api.graph.getNode(frame.id) + expect(raw).toBeDefined() + if (!raw) return + Reflect.deleteProperty(raw as object, 'layoutDirection') + expect(frame.layoutDirection).toBe('AUTO') + }) + + test('itemSpacing', () => { + const api = createAPI() + const frame = api.createFrame() + frame.layoutMode = 'VERTICAL' + frame.itemSpacing = 12 + expect(frame.itemSpacing).toBe(12) + }) + + test('padding', () => { + const api = createAPI() + const frame = api.createFrame() + frame.paddingTop = 16 + frame.paddingRight = 24 + frame.paddingBottom = 16 + frame.paddingLeft = 24 + expect(frame.paddingTop).toBe(16) + expect(frame.paddingRight).toBe(24) + }) + + test('alignment', () => { + const api = createAPI() + const frame = api.createFrame() + frame.layoutMode = 'HORIZONTAL' + frame.primaryAxisAlignItems = 'SPACE_BETWEEN' + frame.counterAxisAlignItems = 'CENTER' + expect(frame.primaryAxisAlignItems).toBe('SPACE_BETWEEN') + expect(frame.counterAxisAlignItems).toBe('CENTER') + }) + + test('layoutWrap', () => { + const api = createAPI() + const frame = api.createFrame() + frame.layoutWrap = 'WRAP' + expect(frame.layoutWrap).toBe('WRAP') + }) +}) diff --git a/tests/engine/figma/api/clone.test.ts b/tests/engine/figma/api/clone.test.ts new file mode 100644 index 000000000..2f386db2a --- /dev/null +++ b/tests/engine/figma/api/clone.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('clone', () => { + test('clone creates a deep copy', () => { + const api = createAPI() + const frame = api.createFrame() + frame.name = 'Original' + frame.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + const child = api.createRectangle() + child.name = 'Child' + frame.appendChild(child) + + const cloned = frame.clone() + expect(cloned.id).not.toBe(frame.id) + expect(cloned.name).toBe('Original') + expect(cloned.fills[0].color.r).toBe(1) + expect(cloned.children.length).toBe(1) + expect(cloned.children[0].name).toBe('Child') + expect(cloned.children[0].id).not.toBe(child.id) + }) +}) diff --git a/tests/engine/figma/api/components.test.ts b/tests/engine/figma/api/components.test.ts new file mode 100644 index 000000000..e8b4434f6 --- /dev/null +++ b/tests/engine/figma/api/components.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from 'bun:test' + +import { expectDefined } from '#tests/helpers/assert' + +import { createAPI } from './helpers' + +describe('components', () => { + test('createInstance from component', () => { + const api = createAPI() + const comp = api.createComponent() + comp.name = 'Button' + comp.resize(200, 40) + const instance = comp.createInstance() + expect(instance.type).toBe('INSTANCE') + expect(expectDefined(instance.mainComponent, 'instance main component').id).toBe(comp.id) + }) +}) diff --git a/tests/engine/figma/api/corner-radius.test.ts b/tests/engine/figma/api/corner-radius.test.ts new file mode 100644 index 000000000..c188666da --- /dev/null +++ b/tests/engine/figma/api/corner-radius.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('corner radius', () => { + test('uniform corner radius', () => { + const api = createAPI() + const rect = api.createRectangle() + rect.cornerRadius = 8 + expect(rect.cornerRadius).toBe(8) + expect(rect.topLeftRadius).toBe(8) + expect(rect.bottomRightRadius).toBe(8) + }) + + test('independent corners return mixed', () => { + const api = createAPI() + const rect = api.createRectangle() + rect.topLeftRadius = 4 + rect.bottomRightRadius = 12 + expect(rect.cornerRadius).toBe(api.mixed) + }) +}) diff --git a/tests/engine/figma/api/create-component-from-node.test.ts b/tests/engine/figma/api/create-component-from-node.test.ts new file mode 100644 index 000000000..2dafc957f --- /dev/null +++ b/tests/engine/figma/api/create-component-from-node.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('createComponentFromNode', () => { + test('converts frame to component', () => { + const api = createAPI() + const frame = api.createFrame() + frame.name = 'MyButton' + frame.resize(200, 50) + const child = api.createRectangle() + child.name = 'Background' + frame.appendChild(child) + const frameId = frame.id + + const comp = api.createComponentFromNode(frame) + expect(comp.type).toBe('COMPONENT') + expect(comp.name).toBe('MyButton') + expect(comp.width).toBe(200) + expect(comp.height).toBe(50) + expect(comp.children.length).toBe(1) + expect(comp.children[0].name).toBe('Background') + expect(api.getNodeById(frameId)).toBeNull() + }) +}) diff --git a/tests/engine/figma/api/document-and-pages.test.ts b/tests/engine/figma/api/document-and-pages.test.ts new file mode 100644 index 000000000..cd641c6e5 --- /dev/null +++ b/tests/engine/figma/api/document-and-pages.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' + +import { expectDefined } from '#tests/helpers/assert' + +import { createAPI } from './helpers' + +describe('document & pages', () => { + test('root has pages as children', () => { + const api = createAPI() + expect(api.root.children.length).toBeGreaterThan(0) + expect(api.root.children[0].type).toBe('CANVAS') + }) + + test('currentPage is first page', () => { + const api = createAPI() + expect(api.currentPage.type).toBe('CANVAS') + expect(api.currentPage.name).toBe('Page 1') + }) + + test('createPage adds a new page', () => { + const api = createAPI() + const page = api.createPage() + expect(page.type).toBe('CANVAS') + expect(api.root.children.length).toBe(2) + }) + + test('currentPage can be switched', () => { + const api = createAPI() + const page2 = api.createPage() + page2.name = 'Page 2' + api.currentPage = page2 + expect(api.currentPage.name).toBe('Page 2') + }) + + test('getNodeById returns proxy', () => { + const api = createAPI() + const frame = api.createFrame() + const found = api.getNodeById(frame.id) + expect(expectDefined(found, 'found frame').id).toBe(frame.id) + }) + + test('getNodeById returns null for unknown id', () => { + const api = createAPI() + expect(api.getNodeById('nonexistent')).toBeNull() + }) +}) diff --git a/tests/engine/figma/api/expanded.test.ts b/tests/engine/figma/api/expanded.test.ts new file mode 100644 index 000000000..958a1eb68 --- /dev/null +++ b/tests/engine/figma/api/expanded.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('expanded', () => { + test('defaults to true', () => { + const api = createAPI() + const frame = api.createFrame() + expect(frame.expanded).toBe(true) + }) + + test('can collapse', () => { + const api = createAPI() + const frame = api.createFrame() + frame.expanded = false + expect(frame.expanded).toBe(false) + }) +}) diff --git a/tests/engine/figma/api/find-all-with-criteria.test.ts b/tests/engine/figma/api/find-all-with-criteria.test.ts new file mode 100644 index 000000000..4e8c1c1a0 --- /dev/null +++ b/tests/engine/figma/api/find-all-with-criteria.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('findAllWithCriteria', () => { + test('filters by type', () => { + const api = createAPI() + api.createFrame() + api.createRectangle() + api.createText() + api.createRectangle() + const rects = api.currentPage.findAllWithCriteria({ types: ['RECTANGLE'] }) + expect(rects.length).toBe(2) + expect(rects.every((n) => n.type === 'RECTANGLE')).toBe(true) + }) + + test('filters by multiple types', () => { + const api = createAPI() + api.createFrame() + api.createRectangle() + api.createText() + const result = api.currentPage.findAllWithCriteria({ types: ['FRAME', 'TEXT'] }) + expect(result.length).toBe(2) + }) +}) diff --git a/tests/engine/figma/api/frozen-arrays.test.ts b/tests/engine/figma/api/frozen-arrays.test.ts new file mode 100644 index 000000000..aa12e6bfd --- /dev/null +++ b/tests/engine/figma/api/frozen-arrays.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'bun:test' + +import { type Fill } from '@open-pencil/core' + +import { createAPI } from './helpers' + +describe('frozen arrays', () => { + test('fills returns frozen clone', () => { + const api = createAPI() + const rect = api.createRectangle() + rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + const fills = rect.fills + expect(Object.isFrozen(fills)).toBe(true) + expect(() => { + ;(fills as Fill[]).push({} as Fill) + }).toThrow() + }) + + test('mutating returned fills does not affect node', () => { + const api = createAPI() + const rect = api.createRectangle() + rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + const fills = rect.fills as Fill[] + try { + fills[0].color.r = 0 + } catch (error) { + expect(error).toBeInstanceOf(TypeError) + } + expect(rect.fills[0].color.r).toBe(1) + }) +}) diff --git a/tests/engine/figma/api/grouping.test.ts b/tests/engine/figma/api/grouping.test.ts new file mode 100644 index 000000000..919436e9a --- /dev/null +++ b/tests/engine/figma/api/grouping.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'bun:test' + +import { expectDefined } from '#tests/helpers/assert' + +import { createAPI } from './helpers' + +describe('grouping', () => { + test('group creates GROUP with children', () => { + const api = createAPI() + const a = api.createRectangle() + const b = api.createEllipse() + const group = api.group([a, b], api.currentPage) + expect(group.type).toBe('GROUP') + expect(group.children.length).toBe(2) + }) + + test('ungroup dissolves group', () => { + const api = createAPI() + const a = api.createRectangle() + const b = api.createEllipse() + const group = api.group([a, b], api.currentPage) + const groupId = group.id + api.ungroup(group) + expect(api.getNodeById(groupId)).toBeNull() + expect(expectDefined(a.parent, 'ungrouped parent').id).toBe(api.currentPage.id) + }) +}) diff --git a/tests/engine/figma/api/helpers.ts b/tests/engine/figma/api/helpers.ts new file mode 100644 index 000000000..eff817b89 --- /dev/null +++ b/tests/engine/figma/api/helpers.ts @@ -0,0 +1,4 @@ +import { FigmaAPI, SceneGraph } from '@open-pencil/core' +export function createAPI(): FigmaAPI { + return new FigmaAPI(new SceneGraph()) +} diff --git a/tests/engine/figma/api/internals-not-exposed.test.ts b/tests/engine/figma/api/internals-not-exposed.test.ts new file mode 100644 index 000000000..f320822aa --- /dev/null +++ b/tests/engine/figma/api/internals-not-exposed.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('internals not exposed', () => { + test('node has no _id, _graph, _api properties', () => { + const api = createAPI() + const frame = api.createFrame() + expect('_id' in frame).toBe(false) + expect('_graph' in frame).toBe(false) + expect('_api' in frame).toBe(false) + }) +}) diff --git a/tests/engine/figma/api/layout-sizing.test.ts b/tests/engine/figma/api/layout-sizing.test.ts new file mode 100644 index 000000000..165cf16cf --- /dev/null +++ b/tests/engine/figma/api/layout-sizing.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' + +import { getNodeOrThrow } from '#tests/helpers/assert' + +import { createAPI } from './helpers' + +describe('layout sizing', () => { + test('child in horizontal parent: h=primary, v=counter', () => { + const api = createAPI() + const parent = api.createFrame() + parent.layoutMode = 'HORIZONTAL' + const child = api.createFrame() + parent.appendChild(child) + child.layoutSizingHorizontal = 'FILL' + child.layoutSizingVertical = 'HUG' + expect(child.layoutSizingHorizontal).toBe('FILL') + expect(child.layoutSizingVertical).toBe('HUG') + }) + + test('child in vertical parent: h=counter, v=primary', () => { + const api = createAPI() + const parent = api.createFrame() + parent.layoutMode = 'VERTICAL' + const child = api.createFrame() + parent.appendChild(child) + child.layoutSizingHorizontal = 'FILL' + child.layoutSizingVertical = 'HUG' + expect(child.layoutSizingHorizontal).toBe('FILL') + expect(child.layoutSizingVertical).toBe('HUG') + }) + + test('auto-layout frame that is also a child of auto-layout', () => { + const api = createAPI() + const outer = api.createFrame() + outer.layoutMode = 'HORIZONTAL' + const inner = api.createFrame() + inner.layoutMode = 'VERTICAL' + outer.appendChild(inner) + inner.layoutSizingHorizontal = 'FILL' + expect(inner.layoutSizingHorizontal).toBe('FILL') + expect(inner.layoutMode).toBe('VERTICAL') + }) + + test('HORIZONTAL child in VERTICAL parent: sizing maps to correct raw axis', () => { + const api = createAPI() + const parent = api.createFrame() + parent.layoutMode = 'VERTICAL' + parent.resize(375, 812) + const child = api.createFrame() + child.layoutMode = 'HORIZONTAL' + parent.appendChild(child) + child.layoutSizingVertical = 'FIXED' + child.resize(375, 44) + expect(child.layoutSizingVertical).toBe('FIXED') + const raw = getNodeOrThrow(api.graph, child.id) + expect(raw.counterAxisSizing).toBe('FIXED') + }) + + test('VERTICAL child in HORIZONTAL parent: sizing maps to correct raw axis', () => { + const api = createAPI() + const parent = api.createFrame() + parent.layoutMode = 'HORIZONTAL' + parent.resize(800, 600) + const child = api.createFrame() + child.layoutMode = 'VERTICAL' + parent.appendChild(child) + child.layoutSizingHorizontal = 'FIXED' + child.resize(200, 600) + expect(child.layoutSizingHorizontal).toBe('FIXED') + const raw = getNodeOrThrow(api.graph, child.id) + expect(raw.counterAxisSizing).toBe('FIXED') + }) +}) diff --git a/tests/engine/figma/api/mask.test.ts b/tests/engine/figma/api/mask.test.ts new file mode 100644 index 000000000..4effa4f47 --- /dev/null +++ b/tests/engine/figma/api/mask.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('mask', () => { + test('isMask defaults to false', () => { + const api = createAPI() + const rect = api.createRectangle() + expect(rect.isMask).toBe(false) + expect(rect.maskType).toBe('ALPHA') + }) + + test('can set mask properties', () => { + const api = createAPI() + const rect = api.createRectangle() + rect.isMask = true + rect.maskType = 'LUMINANCE' + expect(rect.isMask).toBe(true) + expect(rect.maskType).toBe('LUMINANCE') + }) +}) diff --git a/tests/engine/figma/api/min-max-dimensions.test.ts b/tests/engine/figma/api/min-max-dimensions.test.ts new file mode 100644 index 000000000..0689a3308 --- /dev/null +++ b/tests/engine/figma/api/min-max-dimensions.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('min/max dimensions', () => { + test('defaults to null', () => { + const api = createAPI() + const frame = api.createFrame() + expect(frame.minWidth).toBeNull() + expect(frame.maxWidth).toBeNull() + expect(frame.minHeight).toBeNull() + expect(frame.maxHeight).toBeNull() + }) + + test('can set and read', () => { + const api = createAPI() + const frame = api.createFrame() + frame.minWidth = 50 + frame.maxWidth = 400 + frame.minHeight = 30 + frame.maxHeight = 600 + expect(frame.minWidth).toBe(50) + expect(frame.maxWidth).toBe(400) + expect(frame.minHeight).toBe(30) + expect(frame.maxHeight).toBe(600) + }) + + test('can reset to null', () => { + const api = createAPI() + const frame = api.createFrame() + frame.minWidth = 50 + frame.minWidth = null + expect(frame.minWidth).toBeNull() + }) +}) diff --git a/tests/engine/figma/api/node-creation.test.ts b/tests/engine/figma/api/node-creation.test.ts new file mode 100644 index 000000000..deb5be762 --- /dev/null +++ b/tests/engine/figma/api/node-creation.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' + +import { expectDefined } from '#tests/helpers/assert' + +import { createAPI } from './helpers' + +describe('node creation', () => { + test('createFrame', () => { + const api = createAPI() + const frame = api.createFrame() + expect(frame.type).toBe('FRAME') + expect(expectDefined(frame.parent, 'frame parent').id).toBe(api.currentPage.id) + }) + + test('createRectangle', () => { + const api = createAPI() + const rect = api.createRectangle() + expect(rect.type).toBe('RECTANGLE') + }) + + test('createEllipse', () => { + const api = createAPI() + expect(api.createEllipse().type).toBe('ELLIPSE') + }) + + test('createText', () => { + const api = createAPI() + expect(api.createText().type).toBe('TEXT') + }) + + test('createLine', () => { + const api = createAPI() + expect(api.createLine().type).toBe('LINE') + }) + + test('createPolygon', () => { + const api = createAPI() + expect(api.createPolygon().type).toBe('POLYGON') + }) + + test('createStar', () => { + const api = createAPI() + expect(api.createStar().type).toBe('STAR') + }) + + test('createVector', () => { + const api = createAPI() + expect(api.createVector().type).toBe('VECTOR') + }) + + test('createComponent', () => { + const api = createAPI() + expect(api.createComponent().type).toBe('COMPONENT') + }) + + test('createSection', () => { + const api = createAPI() + expect(api.createSection().type).toBe('SECTION') + }) + + test('created node is child of current page', () => { + const api = createAPI() + const frame = api.createFrame() + expect(api.currentPage.children.some((c) => c.id === frame.id)).toBe(true) + }) +}) diff --git a/tests/engine/figma/api/property-access.test.ts b/tests/engine/figma/api/property-access.test.ts new file mode 100644 index 000000000..d666ea8dc --- /dev/null +++ b/tests/engine/figma/api/property-access.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('property access', () => { + test('name get/set', () => { + const api = createAPI() + const frame = api.createFrame() + frame.name = 'Card' + expect(frame.name).toBe('Card') + }) + + test('position get/set', () => { + const api = createAPI() + const frame = api.createFrame() + frame.x = 50 + frame.y = 100 + expect(frame.x).toBe(50) + expect(frame.y).toBe(100) + }) + + test('resize', () => { + const api = createAPI() + const frame = api.createFrame() + frame.resize(300, 200) + expect(frame.width).toBe(300) + expect(frame.height).toBe(200) + }) + + test('fills get/set', () => { + const api = createAPI() + const rect = api.createRectangle() + rect.fills = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }] + expect(rect.fills.length).toBe(1) + expect(rect.fills[0].color.r).toBe(1) + }) + + test('opacity get/set', () => { + const api = createAPI() + const frame = api.createFrame() + frame.opacity = 0.5 + expect(frame.opacity).toBe(0.5) + }) + + test('visible get/set', () => { + const api = createAPI() + const frame = api.createFrame() + frame.visible = false + expect(frame.visible).toBe(false) + }) + + test('locked get/set', () => { + const api = createAPI() + const frame = api.createFrame() + frame.locked = true + expect(frame.locked).toBe(true) + }) + + test('rotation get/set', () => { + const api = createAPI() + const frame = api.createFrame() + frame.rotation = 45 + expect(frame.rotation).toBe(45) + }) + + test('clipsContent get/set', () => { + const api = createAPI() + const frame = api.createFrame() + frame.clipsContent = true + expect(frame.clipsContent).toBe(true) + }) +}) diff --git a/tests/engine/figma/api/selection.test.ts b/tests/engine/figma/api/selection.test.ts new file mode 100644 index 000000000..4c9c7d079 --- /dev/null +++ b/tests/engine/figma/api/selection.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('selection', () => { + test('selection get/set', () => { + const api = createAPI() + const frame = api.createFrame() + api.currentPage.selection = [frame] + expect(api.currentPage.selection.length).toBe(1) + expect(api.currentPage.selection[0].id).toBe(frame.id) + }) + + test('selection empty by default', () => { + const api = createAPI() + expect(api.currentPage.selection).toEqual([]) + }) +}) diff --git a/tests/engine/figma/api/serialization.test.ts b/tests/engine/figma/api/serialization.test.ts new file mode 100644 index 000000000..7f1a158e7 --- /dev/null +++ b/tests/engine/figma/api/serialization.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('serialization', () => { + test('toJSON returns clean object', () => { + const api = createAPI() + const frame = api.createFrame() + frame.name = 'Card' + frame.resize(300, 200) + frame.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }] + const json = frame.toJSON() + expect(json.name).toBe('Card') + expect(json.width).toBe(300) + expect(json.fills).toBeDefined() + }) + + test('toJSON includes children', () => { + const api = createAPI() + const frame = api.createFrame() + const rect = api.createRectangle() + frame.appendChild(rect) + const json = frame.toJSON() as { children?: unknown[] } + expect(json.children).toBeDefined() + expect(json.children?.length).toBe(1) + }) + + test('toString returns readable string', () => { + const api = createAPI() + const frame = api.createFrame() + frame.name = 'Card' + expect(frame.toString()).toContain('FRAME') + expect(frame.toString()).toContain('Card') + }) +}) diff --git a/tests/engine/figma/api/stroke-details.test.ts b/tests/engine/figma/api/stroke-details.test.ts new file mode 100644 index 000000000..4212e0545 --- /dev/null +++ b/tests/engine/figma/api/stroke-details.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('stroke details', () => { + test('strokeWeight and strokeAlign', () => { + const api = createAPI() + const rect = api.createRectangle() + rect.strokes = [ + { color: { r: 0, g: 0, b: 0, a: 1 }, weight: 2, opacity: 1, visible: true, align: 'CENTER' } + ] + expect(rect.strokeWeight).toBe(2) + expect(rect.strokeAlign).toBe('CENTER') + rect.strokeWeight = 4 + expect(rect.strokeWeight).toBe(4) + }) + + test('strokeCap and strokeJoin', () => { + const api = createAPI() + const line = api.createLine() + expect(line.strokeCap).toBe('NONE') + expect(line.strokeJoin).toBe('MITER') + line.strokeCap = 'ROUND' + line.strokeJoin = 'BEVEL' + expect(line.strokeCap).toBe('ROUND') + expect(line.strokeJoin).toBe('BEVEL') + }) + + test('strokeMiterLimit', () => { + const api = createAPI() + const rect = api.createRectangle() + expect(rect.strokeMiterLimit).toBe(4) + rect.strokeMiterLimit = 8 + expect(rect.strokeMiterLimit).toBe(8) + }) + + test('individual stroke weights', () => { + const api = createAPI() + const frame = api.createFrame() + frame.strokeTopWeight = 1 + frame.strokeRightWeight = 2 + frame.strokeBottomWeight = 3 + frame.strokeLeftWeight = 4 + expect(frame.strokeTopWeight).toBe(1) + expect(frame.strokeRightWeight).toBe(2) + expect(frame.strokeBottomWeight).toBe(3) + expect(frame.strokeLeftWeight).toBe(4) + }) +}) diff --git a/tests/engine/figma/api/stubs.test.ts b/tests/engine/figma/api/stubs.test.ts new file mode 100644 index 000000000..a6bf935a5 --- /dev/null +++ b/tests/engine/figma/api/stubs.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('stubs', () => { + test('loadFontAsync resolves', async () => { + const api = createAPI() + await api.loadFontAsync({ family: 'Inter', style: 'Regular' }) + }) + + test('mixed is a symbol', () => { + const api = createAPI() + expect(typeof api.mixed).toBe('symbol') + }) +}) diff --git a/tests/engine/figma/api/text-extras.test.ts b/tests/engine/figma/api/text-extras.test.ts new file mode 100644 index 000000000..12dceb140 --- /dev/null +++ b/tests/engine/figma/api/text-extras.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('text extras', () => { + test('textTruncation', () => { + const api = createAPI() + const text = api.createText() + expect(text.textTruncation).toBe('DISABLED') + text.textTruncation = 'ENDING' + expect(text.textTruncation).toBe('ENDING') + }) + + test('autoRename', () => { + const api = createAPI() + const text = api.createText() + expect(text.autoRename).toBe(true) + text.autoRename = false + expect(text.autoRename).toBe(false) + }) + + test('insertCharacters', () => { + const api = createAPI() + const text = api.createText() + text.characters = 'Hello World' + text.insertCharacters(5, ' Beautiful') + expect(text.characters).toBe('Hello Beautiful World') + }) + + test('deleteCharacters', () => { + const api = createAPI() + const text = api.createText() + text.characters = 'Hello Beautiful World' + text.deleteCharacters(5, 15) + expect(text.characters).toBe('Hello World') + }) +}) diff --git a/tests/engine/figma/api/text.test.ts b/tests/engine/figma/api/text.test.ts new file mode 100644 index 000000000..bd29abb5c --- /dev/null +++ b/tests/engine/figma/api/text.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test' + +import { createAPI } from './helpers' + +describe('text', () => { + test('characters maps to text content', () => { + const api = createAPI() + const text = api.createText() + text.characters = 'Hello World' + expect(text.characters).toBe('Hello World') + }) + + test('fontSize get/set', () => { + const api = createAPI() + const text = api.createText() + text.fontSize = 24 + expect(text.fontSize).toBe(24) + }) + + test('fontName get/set', () => { + const api = createAPI() + const text = api.createText() + text.fontName = { family: 'Roboto', style: 'Bold' } + expect(text.fontName.family).toBe('Roboto') + expect(text.fontWeight).toBe(700) + }) + + test('fontName italic', () => { + const api = createAPI() + const text = api.createText() + text.fontName = { family: 'Inter', style: 'Bold Italic' } + expect(text.fontName.family).toBe('Inter') + expect(text.fontWeight).toBe(700) + expect(text.fontName.style).toBe('Bold Italic') + }) + + test('textAlignHorizontal', () => { + const api = createAPI() + const text = api.createText() + text.textAlignHorizontal = 'CENTER' + expect(text.textAlignHorizontal).toBe('CENTER') + }) + + test('textDirection', () => { + const api = createAPI() + const text = api.createText() + text.textDirection = 'RTL' + expect(text.textDirection).toBe('RTL') + }) + + test('textAutoResize', () => { + const api = createAPI() + const text = api.createText() + text.textAutoResize = 'WIDTH_AND_HEIGHT' + expect(text.textAutoResize).toBe('WIDTH_AND_HEIGHT') + }) +}) diff --git a/tests/engine/figma/api/traversal.test.ts b/tests/engine/figma/api/traversal.test.ts new file mode 100644 index 000000000..18c40e86a --- /dev/null +++ b/tests/engine/figma/api/traversal.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' + +import { expectDefined } from '#tests/helpers/assert' + +import { createAPI } from './helpers' + +describe('traversal', () => { + test('findAll finds deep descendants', () => { + const api = createAPI() + const parent = api.createFrame() + parent.name = 'Container' + const child = api.createFrame() + child.name = 'Card' + parent.appendChild(child) + const text = api.createText() + text.characters = 'Title' + text.name = 'Title' + child.appendChild(text) + + const found = parent.findAll((n) => n.type === 'TEXT') + expect(found.length).toBe(1) + expect(found[0].characters).toBe('Title') + }) + + test('findAll without callback returns all', () => { + const api = createAPI() + const parent = api.createFrame() + const a = api.createRectangle() + const b = api.createText() + parent.appendChild(a) + parent.appendChild(b) + expect(parent.findAll().length).toBe(2) + }) + + test('findOne returns first match', () => { + const api = createAPI() + const parent = api.createFrame() + const a = api.createRectangle() + a.name = 'First' + const b = api.createRectangle() + b.name = 'Second' + parent.appendChild(a) + parent.appendChild(b) + const found = parent.findOne((n) => n.type === 'RECTANGLE') + expect(expectDefined(found, 'first rectangle').name).toBe('First') + }) + + test('findChild searches direct children only', () => { + const api = createAPI() + const parent = api.createFrame() + const child = api.createFrame() + const deep = api.createText() + deep.name = 'Deep' + parent.appendChild(child) + child.appendChild(deep) + + expect(parent.findChild((n) => n.name === 'Deep')).toBeNull() + expect(child.findChild((n) => n.name === 'Deep')).not.toBeNull() + }) + + test('findChildren returns direct children matching', () => { + const api = createAPI() + const parent = api.createFrame() + const a = api.createRectangle() + const b = api.createText() + const c = api.createRectangle() + parent.appendChild(a) + parent.appendChild(b) + parent.appendChild(c) + const rects = parent.findChildren((n) => n.type === 'RECTANGLE') + expect(rects.length).toBe(2) + }) +}) diff --git a/tests/engine/figma/api/tree-operations.test.ts b/tests/engine/figma/api/tree-operations.test.ts new file mode 100644 index 000000000..f204646db --- /dev/null +++ b/tests/engine/figma/api/tree-operations.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' + +import { expectDefined } from '#tests/helpers/assert' + +import { createAPI } from './helpers' + +describe('tree operations', () => { + test('appendChild reparents', () => { + const api = createAPI() + const parent = api.createFrame() + const child = api.createRectangle() + parent.appendChild(child) + expect(parent.children.length).toBe(1) + expect(parent.children[0].id).toBe(child.id) + expect(expectDefined(child.parent, 'child parent').id).toBe(parent.id) + }) + + test('insertChild at index', () => { + const api = createAPI() + const parent = api.createFrame() + const a = api.createRectangle() + const b = api.createRectangle() + const c = api.createRectangle() + parent.appendChild(a) + parent.appendChild(b) + parent.insertChild(1, c) + expect(parent.children.map((c) => c.id)).toEqual([a.id, c.id, b.id]) + }) + + test('remove deletes node', () => { + const api = createAPI() + const frame = api.createFrame() + const id = frame.id + frame.remove() + expect(api.getNodeById(id)).toBeNull() + }) + + test('removed property', () => { + const api = createAPI() + const frame = api.createFrame() + expect(frame.removed).toBe(false) + frame.remove() + expect(frame.removed).toBe(true) + }) +}) diff --git a/tests/engine/figma/api/variables.test.ts b/tests/engine/figma/api/variables.test.ts new file mode 100644 index 000000000..075454bca --- /dev/null +++ b/tests/engine/figma/api/variables.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'bun:test' + +import { expectDefined } from '#tests/helpers/assert' + +import { createAPI } from './helpers' + +describe('variables', () => { + test('getLocalVariables returns empty by default', () => { + const api = createAPI() + expect(api.getLocalVariables()).toEqual([]) + }) + + test('getLocalVariables returns added variables', () => { + const api = createAPI() + api.graph.addCollection({ + id: 'col1', + name: 'Colors', + modes: [{ modeId: 'mode1', name: 'Default' }], + defaultModeId: 'mode1', + variableIds: [] + }) + api.graph.addVariable({ + id: 'var1', + name: 'primary', + type: 'COLOR', + collectionId: 'col1', + valuesByMode: { mode1: { r: 1, g: 0, b: 0, a: 1 } }, + description: '', + hiddenFromPublishing: false + }) + expect(api.getLocalVariables().length).toBe(1) + expect(api.getLocalVariables('COLOR').length).toBe(1) + expect(api.getLocalVariables('FLOAT').length).toBe(0) + }) + + test('getVariableById', () => { + const api = createAPI() + api.graph.addCollection({ + id: 'col1', + name: 'Spacing', + modes: [{ modeId: 'mode1', name: 'Default' }], + defaultModeId: 'mode1', + variableIds: [] + }) + api.graph.addVariable({ + id: 'var1', + name: 'spacing-sm', + type: 'FLOAT', + collectionId: 'col1', + valuesByMode: { mode1: 8 }, + description: '', + hiddenFromPublishing: false + }) + const v = api.getVariableById('var1') + expect(expectDefined(v, 'spacing variable').name).toBe('spacing-sm') + expect(api.getVariableById('nonexistent')).toBeNull() + }) + + test('getLocalVariableCollections', () => { + const api = createAPI() + api.graph.addCollection({ + id: 'col1', + name: 'Colors', + modes: [{ modeId: 'mode1', name: 'Default' }], + defaultModeId: 'mode1', + variableIds: [] + }) + const cols = api.getLocalVariableCollections() + expect(cols.length).toBe(1) + expect(cols[0].name).toBe('Colors') + }) + + test('getVariableCollectionById', () => { + const api = createAPI() + api.graph.addCollection({ + id: 'col1', + name: 'Colors', + modes: [{ modeId: 'mode1', name: 'Default' }], + defaultModeId: 'mode1', + variableIds: [] + }) + expect(api.getVariableCollectionById('col1')?.name).toBe('Colors') + expect(api.getVariableCollectionById('nonexistent')).toBeNull() + }) +})