208 lines
6.2 KiB
TypeScript
208 lines
6.2 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { FigmaAPI, SceneGraph, matchByXPath, nodeToXPath, queryByXPath } from '@open-pencil/core'
|
|
|
|
import { expectDefined } from '#tests/helpers/assert'
|
|
|
|
function setup() {
|
|
const graph = new SceneGraph()
|
|
const figma = new FigmaAPI(graph)
|
|
return { graph, figma }
|
|
}
|
|
|
|
describe('queryByXPath', () => {
|
|
test('finds nodes by type', async () => {
|
|
const { graph, figma } = setup()
|
|
figma.createRectangle()
|
|
figma.createRectangle()
|
|
figma.createText()
|
|
|
|
const results = await queryByXPath(graph, '//RECTANGLE')
|
|
expect(results.length).toBe(2)
|
|
expect(results.every((n) => n.type === 'RECTANGLE')).toBe(true)
|
|
})
|
|
|
|
test('filters by attribute comparison', async () => {
|
|
const { graph, figma } = setup()
|
|
const small = figma.createRectangle()
|
|
small.resize(50, 50)
|
|
small.name = 'Small'
|
|
|
|
const big = figma.createRectangle()
|
|
big.resize(300, 300)
|
|
big.name = 'Big'
|
|
|
|
const results = await queryByXPath(graph, '//RECTANGLE[@width < 200]')
|
|
expect(results.length).toBe(1)
|
|
expect(results[0].name).toBe('Small')
|
|
})
|
|
|
|
test('handles descendant axis //FRAME//TEXT', async () => {
|
|
const { graph, figma } = setup()
|
|
const frame = figma.createFrame()
|
|
frame.resize(400, 400)
|
|
frame.name = 'Container'
|
|
|
|
const text = figma.createText()
|
|
text.name = 'Inside'
|
|
frame.appendChild(text)
|
|
|
|
const outsideText = figma.createText()
|
|
outsideText.name = 'Outside'
|
|
|
|
const results = await queryByXPath(graph, '//FRAME//TEXT')
|
|
expect(results.length).toBe(1)
|
|
expect(results[0].name).toBe('Inside')
|
|
})
|
|
|
|
test('respects limit option', async () => {
|
|
const { graph, figma } = setup()
|
|
for (let i = 0; i < 10; i++) {
|
|
const r = figma.createRectangle()
|
|
r.name = `Rect ${i}`
|
|
}
|
|
|
|
const results = await queryByXPath(graph, '//RECTANGLE', { limit: 3 })
|
|
expect(results.length).toBe(3)
|
|
})
|
|
|
|
test('filters by page option', async () => {
|
|
const { graph, figma } = setup()
|
|
const rect1 = figma.createRectangle()
|
|
rect1.name = 'Page1Rect'
|
|
const page1Name = figma.currentPage.name
|
|
|
|
const page2 = figma.createPage()
|
|
page2.name = 'Page 2'
|
|
figma.currentPage = page2
|
|
const rect2 = figma.createRectangle()
|
|
rect2.name = 'Page2Rect'
|
|
|
|
const page1Results = await queryByXPath(graph, '//RECTANGLE', { page: page1Name })
|
|
expect(page1Results.length).toBe(1)
|
|
expect(page1Results[0].name).toBe('Page1Rect')
|
|
|
|
const page2Results = await queryByXPath(graph, '//RECTANGLE', { page: 'Page 2' })
|
|
expect(page2Results.length).toBe(1)
|
|
expect(page2Results[0].name).toBe('Page2Rect')
|
|
})
|
|
|
|
test('returns empty for no matches', async () => {
|
|
const { graph, figma } = setup()
|
|
figma.createRectangle()
|
|
|
|
const results = await queryByXPath(graph, '//ELLIPSE')
|
|
expect(results.length).toBe(0)
|
|
})
|
|
|
|
test('handles invalid selector gracefully (throws)', async () => {
|
|
const { graph } = setup()
|
|
expect(queryByXPath(graph, '///invalid[[[[')).rejects.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('nodeToXPath', () => {
|
|
test('returns null for missing node', () => {
|
|
const { graph } = setup()
|
|
expect(nodeToXPath(graph, 'nonexistent')).toBeNull()
|
|
})
|
|
|
|
test('uses name predicate for unique name among siblings', () => {
|
|
const { graph, figma } = setup()
|
|
const rect = figma.createRectangle()
|
|
rect.name = 'Header'
|
|
const text = figma.createText()
|
|
text.name = 'Title'
|
|
|
|
const xpath = nodeToXPath(graph, rect.id)
|
|
expect(xpath).toBe("//RECTANGLE[@name='Header']")
|
|
})
|
|
|
|
test('includes name even when sole child of that type', () => {
|
|
const { graph, figma } = setup()
|
|
const frame = figma.createFrame()
|
|
frame.name = 'Card'
|
|
const text = figma.createText()
|
|
text.name = 'Label'
|
|
frame.appendChild(text)
|
|
|
|
const xpath = nodeToXPath(graph, text.id)
|
|
expect(xpath).toBe("//FRAME[@name='Card']/TEXT[@name='Label']")
|
|
})
|
|
|
|
test('uses positional predicate for duplicate names', () => {
|
|
const { graph, figma } = setup()
|
|
const r1 = figma.createRectangle()
|
|
r1.name = 'Item'
|
|
const r2 = figma.createRectangle()
|
|
r2.name = 'Item'
|
|
|
|
const xpath1 = nodeToXPath(graph, r1.id)
|
|
const xpath2 = nodeToXPath(graph, r2.id)
|
|
expect(xpath1).toBe('//RECTANGLE[1]')
|
|
expect(xpath2).toBe('//RECTANGLE[2]')
|
|
})
|
|
|
|
test('builds multi-level path', () => {
|
|
const { graph, figma } = setup()
|
|
const frame = figma.createFrame()
|
|
frame.name = 'Container'
|
|
const inner = figma.createFrame()
|
|
inner.name = 'Row'
|
|
frame.appendChild(inner)
|
|
const text = figma.createText()
|
|
text.name = 'Label'
|
|
inner.appendChild(text)
|
|
|
|
const xpath = nodeToXPath(graph, text.id)
|
|
expect(xpath).toBe("//FRAME[@name='Container']/FRAME[@name='Row']/TEXT[@name='Label']")
|
|
})
|
|
|
|
test('round-trips with queryByXPath', async () => {
|
|
const { graph, figma } = setup()
|
|
const frame = figma.createFrame()
|
|
frame.name = 'Sidebar'
|
|
const btn = figma.createRectangle()
|
|
btn.name = 'Button'
|
|
frame.appendChild(btn)
|
|
|
|
const xpath = expectDefined(nodeToXPath(graph, btn.id), 'button XPath')
|
|
const results = await queryByXPath(graph, xpath)
|
|
expect(results.length).toBe(1)
|
|
expect(results[0].id).toBe(btn.id)
|
|
})
|
|
|
|
test('handles names with single quotes', () => {
|
|
const { graph, figma } = setup()
|
|
const rect = figma.createRectangle()
|
|
rect.name = "it's a test"
|
|
|
|
const xpath = nodeToXPath(graph, rect.id)
|
|
expect(xpath).toBe('//RECTANGLE[@name="it\'s a test"]')
|
|
})
|
|
})
|
|
|
|
describe('matchByXPath', () => {
|
|
test('returns true for matching node', async () => {
|
|
const { graph, figma } = setup()
|
|
const rect = figma.createRectangle()
|
|
rect.resize(100, 100)
|
|
rect.name = 'TestRect'
|
|
|
|
const sceneNode = expectDefined(graph.getNode(rect.id), 'matching rectangle')
|
|
const result = await matchByXPath(graph, '@name = "TestRect"', sceneNode)
|
|
expect(result).toBe(true)
|
|
})
|
|
|
|
test('returns false for non-matching node', async () => {
|
|
const { graph, figma } = setup()
|
|
const rect = figma.createRectangle()
|
|
rect.resize(100, 100)
|
|
rect.name = 'Other'
|
|
|
|
const sceneNode = expectDefined(graph.getNode(rect.id), 'non-matching rectangle')
|
|
const result = await matchByXPath(graph, '@name = "TestRect"', sceneNode)
|
|
expect(result).toBe(false)
|
|
})
|
|
})
|