openpencil/tests/engine/scene-graph/individual-strokes.test.ts
Danila Poyarkov 1ed7de6056 chore(lint): reject broad unknown type assertions
- Add AST-based lint checks for broad unknown object assertions and local JsonObject aliases
- Centralize JsonObject in core and package-local MCP RPC JSON typing
- Replace baseline Record<string, unknown> assertions with named shared/domain types
2026-05-25 00:26:02 +03:00

350 lines
9.8 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
import { SceneGraph, FigmaAPI, sceneNodeToKiwi, type Stroke } from '@open-pencil/core'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
function rectWithStroke(graph: SceneGraph, name: string, strokeOverrides: Partial<Stroke> = {}) {
const stroke: Stroke = {
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'CENTER',
...strokeOverrides
}
return graph.createNode('RECTANGLE', pageId(graph), {
name,
x: 0,
y: 0,
width: 100,
height: 100,
strokes: [stroke]
})
}
describe('Stroke align', () => {
test('default stroke align is stored per stroke', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R')
expect(node.strokes[0].align).toBe('CENTER')
})
test('stroke align can be set to INSIDE', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R', { align: 'INSIDE' })
expect(node.strokes[0].align).toBe('INSIDE')
})
test('stroke align can be set to OUTSIDE', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R', { align: 'OUTSIDE' })
expect(node.strokes[0].align).toBe('OUTSIDE')
})
test('updating stroke align preserves other stroke properties', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R', { weight: 5, align: 'CENTER' })
const strokes = [...node.strokes]
strokes[0] = { ...strokes[0], align: 'INSIDE' }
graph.updateNode(node.id, { strokes })
const updated = getNodeOrThrow(graph, node.id)
expect(updated.strokes[0].align).toBe('INSIDE')
expect(updated.strokes[0].weight).toBe(5)
expect(updated.strokes[0].color).toEqual({ r: 0, g: 0, b: 0, a: 1 })
})
})
describe('Individual stroke weights', () => {
test('default independentStrokeWeights is false', () => {
const graph = new SceneGraph()
const node = graph.createNode('RECTANGLE', pageId(graph), { name: 'R' })
expect(node.independentStrokeWeights).toBe(false)
expect(node.borderTopWeight).toBe(0)
expect(node.borderRightWeight).toBe(0)
expect(node.borderBottomWeight).toBe(0)
expect(node.borderLeftWeight).toBe(0)
})
test('enable independent stroke weights', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R')
graph.updateNode(node.id, {
independentStrokeWeights: true,
borderTopWeight: 2,
borderRightWeight: 0,
borderBottomWeight: 4,
borderLeftWeight: 0
})
const updated = getNodeOrThrow(graph, node.id)
expect(updated.independentStrokeWeights).toBe(true)
expect(updated.borderTopWeight).toBe(2)
expect(updated.borderRightWeight).toBe(0)
expect(updated.borderBottomWeight).toBe(4)
expect(updated.borderLeftWeight).toBe(0)
})
test('disable independent stroke weights', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R')
graph.updateNode(node.id, {
independentStrokeWeights: true,
borderTopWeight: 2,
borderRightWeight: 3,
borderBottomWeight: 4,
borderLeftWeight: 5
})
graph.updateNode(node.id, {
independentStrokeWeights: false,
borderTopWeight: 0,
borderRightWeight: 0,
borderBottomWeight: 0,
borderLeftWeight: 0
})
const updated = getNodeOrThrow(graph, node.id)
expect(updated.independentStrokeWeights).toBe(false)
})
test('single side: top only', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R')
graph.updateNode(node.id, {
independentStrokeWeights: true,
borderTopWeight: 3,
borderRightWeight: 0,
borderBottomWeight: 0,
borderLeftWeight: 0
})
const updated = getNodeOrThrow(graph, node.id)
expect(updated.borderTopWeight).toBe(3)
expect(updated.borderRightWeight).toBe(0)
expect(updated.borderBottomWeight).toBe(0)
expect(updated.borderLeftWeight).toBe(0)
})
test('individual weights are independent from stroke weight', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R', { weight: 10 })
graph.updateNode(node.id, {
independentStrokeWeights: true,
borderTopWeight: 1,
borderRightWeight: 2,
borderBottomWeight: 3,
borderLeftWeight: 4
})
const updated = getNodeOrThrow(graph, node.id)
expect(updated.strokes[0].weight).toBe(10)
expect(updated.borderTopWeight).toBe(1)
expect(updated.borderRightWeight).toBe(2)
expect(updated.borderBottomWeight).toBe(3)
expect(updated.borderLeftWeight).toBe(4)
})
})
describe('Instance sync with stroke weights', () => {
test('syncInstances propagates independentStrokeWeights', () => {
const graph = new SceneGraph()
const comp = graph.createNode('COMPONENT', pageId(graph), {
name: 'Comp',
strokes: [
{
color: { r: 1, g: 0, b: 0, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'INSIDE'
}
],
independentStrokeWeights: true,
borderTopWeight: 3,
borderRightWeight: 0,
borderBottomWeight: 3,
borderLeftWeight: 0
})
const instance = expectDefined(
graph.createInstance(comp.id, pageId(graph)),
'component instance'
)
expect(instance.independentStrokeWeights).toBe(true)
expect(instance.borderTopWeight).toBe(3)
expect(instance.borderBottomWeight).toBe(3)
graph.updateNode(comp.id, { borderTopWeight: 5 })
graph.syncInstances(comp.id)
const synced = getNodeOrThrow(graph, instance.id)
expect(synced.borderTopWeight).toBe(5)
})
})
describe('Kiwi serialization', () => {
test('exports stroke align from first stroke', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R', { align: 'OUTSIDE' })
const blobs: Uint8Array[] = []
const changes = sceneNodeToKiwi(
node,
{ sessionID: 1, localID: 0 },
0,
{ value: 100 },
graph,
blobs
)
expect(changes[0].strokeAlign).toBe('OUTSIDE')
})
test('exports default CENTER align', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R')
const blobs: Uint8Array[] = []
const changes = sceneNodeToKiwi(
node,
{ sessionID: 1, localID: 0 },
0,
{ value: 100 },
graph,
blobs
)
expect(changes[0].strokeAlign).toBe('CENTER')
})
test('exports individual stroke weights', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R')
graph.updateNode(node.id, {
independentStrokeWeights: true,
borderTopWeight: 1,
borderRightWeight: 2,
borderBottomWeight: 3,
borderLeftWeight: 4
})
const updated = getNodeOrThrow(graph, node.id)
const blobs: Uint8Array[] = []
const changes = sceneNodeToKiwi(
updated,
{ sessionID: 1, localID: 0 },
0,
{ value: 100 },
graph,
blobs
)
expect(changes[0].borderStrokeWeightsIndependent).toBe(true)
expect(changes[0].borderTopWeight).toBe(1)
expect(changes[0].borderRightWeight).toBe(2)
expect(changes[0].borderBottomWeight).toBe(3)
expect(changes[0].borderLeftWeight).toBe(4)
})
test('does not export individual weights when disabled', () => {
const graph = new SceneGraph()
const node = rectWithStroke(graph, 'R')
const blobs: Uint8Array[] = []
const changes = sceneNodeToKiwi(
node,
{ sessionID: 1, localID: 0 },
0,
{ value: 100 },
graph,
blobs
)
expect(changes[0].borderStrokeWeightsIndependent).toBeUndefined()
expect(changes[0].borderTopWeight).toBeUndefined()
})
})
describe('FigmaAPI stroke properties', () => {
test('strokeTopWeight sets independentStrokeWeights', () => {
const api = new FigmaAPI(new SceneGraph())
const rect = api.createRectangle()
rect.strokes = [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'CENTER'
}
]
rect.strokeTopWeight = 5
const raw = getNodeOrThrow(api.graph, rect.id)
expect(raw.independentStrokeWeights).toBe(true)
expect(raw.borderTopWeight).toBe(5)
})
test('strokeBottomWeight getter returns borderBottomWeight', () => {
const api = new FigmaAPI(new SceneGraph())
const frame = api.createFrame()
api.graph.updateNode(frame.id, { borderBottomWeight: 7 })
expect(frame.strokeBottomWeight).toBe(7)
})
test('strokeAlign getter returns first stroke align', () => {
const api = new FigmaAPI(new SceneGraph())
const rect = api.createRectangle()
rect.strokes = [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'INSIDE'
}
]
expect(rect.strokeAlign).toBe('INSIDE')
})
test('strokeAlign setter updates stroke', () => {
const api = new FigmaAPI(new SceneGraph())
const rect = api.createRectangle()
rect.strokes = [
{
color: { r: 0, g: 0, b: 0, a: 1 },
weight: 2,
opacity: 1,
visible: true,
align: 'CENTER'
}
]
rect.strokeAlign = 'OUTSIDE'
const raw = getNodeOrThrow(api.graph, rect.id)
expect(raw.strokes[0].align).toBe('OUTSIDE')
})
test('all four individual stroke weight setters', () => {
const api = new FigmaAPI(new SceneGraph())
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)
const raw = getNodeOrThrow(api.graph, frame.id)
expect(raw.independentStrokeWeights).toBe(true)
})
})