chore(tests): remove targeted non-null assertions

- Add shared test helpers for required nodes and child ids
- Clean non-null assertions from noisy render, nudge, text undo, and fig roundtrip tests
- Enforce the non-null assertion ban for the cleaned test files
- Remove stale eslint disables around empty functions and broad mock transport typing
This commit is contained in:
Danila Poyarkov 2026-05-06 01:14:50 +03:00
parent 4d3e16c0a2
commit 51eae14e4b
12 changed files with 210 additions and 137 deletions

View file

@ -17,6 +17,17 @@
"rules": {
"typescript/no-explicit-any": "off"
}
},
{
"files": [
"tests/engine/fig-roundtrip.test.ts",
"tests/engine/nudge.test.ts",
"tests/engine/render.test.ts",
"tests/engine/text-edit-undo.test.ts"
],
"rules": {
"typescript/no-non-null-assertion": "error"
}
}
],
"rules": {

View file

@ -68,8 +68,11 @@ export default defineCommand({
const graph = await loadDocument(file)
const figma = new FigmaAPI(graph)
// eslint-disable-next-line no-empty-function -- needed to get AsyncFunction constructor
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
type AsyncFunctionConstructor = new (
...args: string[]
) => (...args: unknown[]) => Promise<unknown>
const AsyncFunction = Object.getPrototypeOf(async () => undefined)
.constructor as AsyncFunctionConstructor
const wrappedCode = code.trim().startsWith('return')
? code
: `return (async () => { ${code} })()`

View file

@ -22,6 +22,8 @@ import type {
} from '#core/scene-graph'
import type { Rect, Vector } from '#core/types'
const noop = () => undefined
export { FigmaNodeProxy } from './proxy'
export type { FigmaFont, FigmaFontName } from './proxy'
@ -385,14 +387,16 @@ export class FigmaAPI implements NodeProxyHost {
notify(message: string): { cancel: () => void } {
if (typeof console !== 'undefined') console.warn(`[figma.notify] ${message}`)
// eslint-disable-next-line no-empty-function
return { cancel() {} }
return { cancel: noop }
}
// eslint-disable-next-line no-empty-function
commitUndo(): void {}
// eslint-disable-next-line no-empty-function
triggerUndo(): void {}
commitUndo(): void {
return undefined
}
triggerUndo(): void {
return undefined
}
exportImage?: (
nodeIds: string[],

View file

@ -21,7 +21,6 @@ export class DrawCallCounter {
for (const method of DRAW_METHODS) {
const original = gl[method].bind(gl) as DrawFunction
this.originals.set(method, original)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- monkey-patching overloaded WebGL draw methods
;(gl[method] as DrawFunction) = (...args: unknown[]) => {
this.count++
original(...args)

View file

@ -9,12 +9,18 @@ export const evalCode = defineTool({
},
mutates: true,
execute: async (figma, { code }) => {
// eslint-disable-next-line no-empty-function
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
type AsyncFunctionConstructor = new (
...args: string[]
) => (...args: unknown[]) => Promise<unknown>
const AsyncFunction = Object.getPrototypeOf(async () => undefined)
.constructor as AsyncFunctionConstructor
const wrapped = code.trim().startsWith('return') ? code : `return (async () => { ${code} })()`
const fn = new AsyncFunction('figma', wrapped)
const result = await fn(figma)
if (result && typeof result === 'object' && 'toJSON' in result) return result.toJSON()
if (result && typeof result === 'object') {
const toJSON = Reflect.get(result, 'toJSON')
if (typeof toJSON === 'function') return toJSON.call(result)
}
if (result !== undefined && result !== null) return result
return { ok: true, message: 'Code executed (no return value)' }
}

View file

@ -8,7 +8,7 @@ import { ACP_AGENTS } from '@open-pencil/core/constants'
import type { getActiveEditorStore } from '@/app/editor/active-store'
import type { ACPAgentID, AIProviderID } from '@open-pencil/core/constants'
import type { UIMessage } from 'ai'
import type { ChatTransport, UIMessage } from 'ai'
import type { ComputedRef, Ref } from 'vue'
type EditorStore = ReturnType<typeof getActiveEditorStore>
@ -110,7 +110,7 @@ export function createToolLoopTransport({
}
})
return new DirectChatTransport({ agent })
return new DirectChatTransport({ agent }) as ChatTransport<UIMessage>
}
export function createChatSessionManager({
@ -130,8 +130,7 @@ export function createChatSessionManager({
let currentChatMessages = new WeakMap<EditorStore, UIMessage[]>()
let chat: Chat<UIMessage> | null = null
let acpTransportInstance: { destroy(): Promise<void> } | null = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only mock transports don't implement full generics
let overrideTransport: (() => any) | null = null
let overrideTransport: (() => ChatTransport<UIMessage>) | null = null
function markTransportDirty() {
transportDirty = true
@ -143,7 +142,7 @@ export function createChatSessionManager({
await acpTransportInstance?.destroy()
const transport = await createACPTransport(providerID.value)
acpTransportInstance = transport
return transport
return transport as ChatTransport<UIMessage>
}
function createTransport(store: EditorStore) {
@ -174,7 +173,7 @@ export function createChatSessionManager({
if (!chat || transportDirty || currentChatStore !== store) {
const messages = currentChatMessages.get(store)
const transport = isACPProvider.value
const transport: ChatTransport<UIMessage> = isACPProvider.value
? await createActiveACPTransport()
: createTransport(store)
chat = new Chat<UIMessage>({ transport, messages })
@ -191,7 +190,7 @@ export function createChatSessionManager({
transportDirty = false
}
function setOverrideTransport(factory: (() => unknown) | null) {
function setOverrideTransport(factory: (() => ChatTransport<UIMessage>) | null) {
overrideTransport = factory
markTransportDirty()
}

3
src/global.d.ts vendored
View file

@ -1,4 +1,5 @@
import type { EditorStore } from '@/app/editor/session/create'
import type { ChatTransport, UIMessage } from 'ai'
declare global {
interface Uint8ArrayConstructor {
@ -38,7 +39,7 @@ declare global {
blob(): Promise<Blob>
}[]
>
__OPEN_PENCIL_SET_TRANSPORT__?(factory: () => unknown): void
__OPEN_PENCIL_SET_TRANSPORT__?(factory: () => ChatTransport<UIMessage>): void
__OPEN_PENCIL_STORE__?: EditorStore
__TEST_WRITE_COUNT__?(): number
__TEST_MOCK_HANDLE__?: FileSystemFileHandle

View file

@ -16,6 +16,7 @@ import {
type Fill
} from '@open-pencil/core'
import { expectDefined } from '../helpers/assert'
import {
childMatching,
childNamed,
@ -567,7 +568,7 @@ describe('roundtrip: export → re-import', () => {
test('preserves fills', () => {
const headerBg = reImportedNodes.find((n) => n.name === 'Header BG')
expect(headerBg).toBeDefined()
expect(headerBg!.fills).toHaveLength(2)
expect(expectDefined(headerBg, 'headerBg').fills).toHaveLength(2)
expect(headerBg.fills[0].type).toBe('SOLID')
expect(headerBg.fills[0].color.r).toBeCloseTo(0.2, 1)
expect(headerBg.fills[1].type).toBe('GRADIENT_LINEAR')
@ -578,13 +579,13 @@ describe('roundtrip: export → re-import', () => {
test('preserves text content', () => {
const title = reImportedNodes.find((n) => n.name === 'Title')
expect(title).toBeDefined()
expect(title!.text).toBe('Hello World')
expect(expectDefined(title, 'title').text).toBe('Hello World')
})
test('preserves text properties', () => {
const title = reImportedNodes.find((n) => n.name === 'Title')
expect(title).toBeDefined()
expect(title!.fontSize).toBe(18)
expect(expectDefined(title, 'title').fontSize).toBe(18)
expect(title.fontFamily).toBe('Inter')
expect(title.fontWeight).toBe(700)
expect(title.textAlignHorizontal).toBe('CENTER')
@ -593,13 +594,13 @@ describe('roundtrip: export → re-import', () => {
test('preserves layout mode', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')
expect(container).toBeDefined()
expect(container!.layoutMode).toBe('VERTICAL')
expect(expectDefined(container, 'container').layoutMode).toBe('VERTICAL')
})
test('preserves layout spacing', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')
expect(container).toBeDefined()
expect(container!.itemSpacing).toBe(16)
expect(expectDefined(container, 'container').itemSpacing).toBe(16)
expect(container.paddingTop).toBe(24)
expect(container.paddingRight).toBe(24)
expect(container.paddingBottom).toBe(24)
@ -609,13 +610,13 @@ describe('roundtrip: export → re-import', () => {
test('preserves corner radius', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')
expect(container).toBeDefined()
expect(container!.cornerRadius).toBe(12)
expect(expectDefined(container, 'container').cornerRadius).toBe(12)
})
test('preserves independent corner radii', () => {
const headerBg = reImportedNodes.find((n) => n.name === 'Header BG')
expect(headerBg).toBeDefined()
expect(headerBg!.independentCorners).toBe(true)
expect(expectDefined(headerBg, 'headerBg').independentCorners).toBe(true)
expect(headerBg.topLeftRadius).toBe(8)
expect(headerBg.topRightRadius).toBe(8)
expect(headerBg.bottomRightRadius).toBe(0)
@ -625,7 +626,7 @@ describe('roundtrip: export → re-import', () => {
test('preserves effects', () => {
const headerBg = reImportedNodes.find((n) => n.name === 'Header BG')
expect(headerBg).toBeDefined()
expect(headerBg!.effects).toHaveLength(1)
expect(expectDefined(headerBg, 'headerBg').effects).toHaveLength(1)
expect(headerBg.effects[0].type).toBe('DROP_SHADOW')
expect(headerBg.effects[0].radius).toBe(8)
expect(headerBg.effects[0].offset.y).toBe(4)
@ -634,7 +635,7 @@ describe('roundtrip: export → re-import', () => {
test('preserves dimensions', () => {
const container = reImportedNodes.find((n) => n.name === 'Container')
expect(container).toBeDefined()
expect(container!.width).toBe(400)
expect(expectDefined(container, 'container').width).toBe(400)
expect(container.height).toBe(300)
})
})
@ -795,7 +796,7 @@ describe('edge cases', () => {
const page = graph.getPages()[0]
const outerUse = graph.getChildren(page.id).find((n) => n.name === 'OuterUse')
expect(outerUse).toBeDefined()
expect(outerUse!.type).toBe('INSTANCE')
expect(expectDefined(outerUse, 'outerUse').type).toBe('INSTANCE')
// Walk down: OuterUse > InnerUse clone > Label clone
const innerClone = graph.getChildren(outerUse?.id ?? '')[0]
@ -1037,7 +1038,7 @@ describe('text node export', () => {
const textNode = [...reimported.getAllNodes()].find((n) => n.name === 'Greeting')
expect(textNode).toBeDefined()
expect(textNode!.type).toBe('TEXT')
expect(expectDefined(textNode, 'textNode').type).toBe('TEXT')
expect(textNode.text).toBe('Hello World')
expect(textNode.fontFamily).toBe('Inter')
expect(textNode.fontSize).toBe(16)
@ -1063,7 +1064,7 @@ describe('text node export', () => {
const textNode = [...reimported.getAllNodes()].find((n) => n.name === 'Multiline')
expect(textNode).toBeDefined()
expect(textNode!.text).toBe('Line 1\nLine 2\nLine 3')
expect(expectDefined(textNode, 'textNode').text).toBe('Line 1\nLine 2\nLine 3')
})
test('derivedTextData fields present in raw binary', async () => {
@ -1221,7 +1222,7 @@ describe('variable roundtrip', () => {
const vars = [...reimported.variables.values()]
const colorVar = vars.find((v) => v.name === 'color/primary')
expect(colorVar).toBeDefined()
expect(colorVar!.type).toBe('COLOR')
expect(expectDefined(colorVar, 'colorVar').type).toBe('COLOR')
const colorVal = Object.values(colorVar.valuesByMode)[0] as {
r: number
g: number
@ -1232,17 +1233,17 @@ describe('variable roundtrip', () => {
const floatVar = vars.find((v) => v.name === 'spacing/base')
expect(floatVar).toBeDefined()
expect(floatVar!.type).toBe('FLOAT')
expect(expectDefined(floatVar, 'floatVar').type).toBe('FLOAT')
expect(Object.values(floatVar.valuesByMode)[0]).toBe(8)
const boolVar = vars.find((v) => v.name === 'visible')
expect(boolVar).toBeDefined()
expect(boolVar!.type).toBe('BOOLEAN')
expect(expectDefined(boolVar, 'boolVar').type).toBe('BOOLEAN')
expect(Object.values(boolVar.valuesByMode)[0]).toBe(true)
const strVar = vars.find((v) => v.name === 'label')
expect(strVar).toBeDefined()
expect(strVar!.type).toBe('STRING')
expect(expectDefined(strVar, 'strVar').type).toBe('STRING')
expect(Object.values(strVar.valuesByMode)[0]).toBe('Hello')
})

View file

@ -2,6 +2,8 @@ import { describe, test, expect } from 'bun:test'
import { createEditor } from '@open-pencil/core/editor'
import { getNodeOrThrow } from '../helpers/assert'
describe('nudgeSelected', () => {
function setup() {
const editor = createEditor()
@ -21,22 +23,22 @@ describe('nudgeSelected', () => {
const { editor, rect } = setup()
editor.nudgeSelected(1, 0)
expect(editor.graph.getNode(rect.id)!.x).toBe(101)
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(101)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(200)
editor.nudgeSelected(0, -1)
expect(editor.graph.getNode(rect.id)!.x).toBe(101)
expect(editor.graph.getNode(rect.id)!.y).toBe(199)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(101)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(199)
})
test('shift nudge moves by 10px', () => {
const { editor, rect } = setup()
editor.nudgeSelected(10, 0)
expect(editor.graph.getNode(rect.id)!.x).toBe(110)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(110)
editor.nudgeSelected(0, 10)
expect(editor.graph.getNode(rect.id)!.y).toBe(210)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(210)
})
test('nudge is undoable as a single entry', () => {
@ -47,15 +49,15 @@ describe('nudgeSelected', () => {
editor.nudgeSelected(1, 0)
editor.flushNudge()
expect(editor.graph.getNode(rect.id)!.x).toBe(103)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(103)
expect(editor.undo.canUndo).toBe(true)
editor.undo.undo()
expect(editor.graph.getNode(rect.id)!.x).toBe(100)
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(100)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(200)
editor.undo.redo()
expect(editor.graph.getNode(rect.id)!.x).toBe(103)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(103)
})
test('nudge skips locked nodes', () => {
@ -64,8 +66,8 @@ describe('nudgeSelected', () => {
editor.graph.updateNode(rect.id, { locked: true })
editor.nudgeSelected(10, 10)
editor.flushNudge()
expect(editor.graph.getNode(rect.id)!.x).toBe(100)
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(100)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(200)
expect(editor.undo.canUndo).toBe(false)
})
@ -93,10 +95,10 @@ describe('nudgeSelected', () => {
editor.nudgeSelected(-5, 3)
editor.flushNudge()
expect(editor.graph.getNode(rect.id)!.x).toBe(95)
expect(editor.graph.getNode(rect.id)!.y).toBe(203)
expect(editor.graph.getNode(rect2.id)!.x).toBe(295)
expect(editor.graph.getNode(rect2.id)!.y).toBe(403)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(95)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(203)
expect(getNodeOrThrow(editor.graph, rect2.id).x).toBe(295)
expect(getNodeOrThrow(editor.graph, rect2.id).y).toBe(403)
})
test('separate nudge sequences create separate undo entries', () => {
@ -108,15 +110,15 @@ describe('nudgeSelected', () => {
editor.nudgeSelected(0, 5)
editor.flushNudge()
expect(editor.graph.getNode(rect.id)!.x).toBe(105)
expect(editor.graph.getNode(rect.id)!.y).toBe(205)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(105)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(205)
editor.undo.undo()
expect(editor.graph.getNode(rect.id)!.x).toBe(105)
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(105)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(200)
editor.undo.undo()
expect(editor.graph.getNode(rect.id)!.x).toBe(100)
expect(editor.graph.getNode(rect.id)!.y).toBe(200)
expect(getNodeOrThrow(editor.graph, rect.id).x).toBe(100)
expect(getNodeOrThrow(editor.graph, rect.id).y).toBe(200)
})
})

View file

@ -19,6 +19,7 @@ import {
node
} from '@open-pencil/core'
import { expectDefined, getNodeOrThrow, childIdAt } from '../helpers/assert'
import { makeSceneGraph } from '../helpers/scene'
describe('TreeNode builders', () => {
@ -46,7 +47,7 @@ describe('TreeNode builders', () => {
]
})
expect(tree.children.length).toBe(2)
expect(isTreeNode(tree.children[0]!)).toBe(true)
expect(isTreeNode(expectDefined(tree.children[0], 'first tree child'))).toBe(true)
const bg = tree.children[0] as ReturnType<typeof Rectangle>
expect(bg.type).toBe('rectangle')
expect(bg.props.name).toBe('Bg')
@ -76,11 +77,11 @@ describe('renderTree', () => {
expect(result.name).toBe('MyFrame')
expect(result.type).toBe('FRAME')
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.width).toBe(200)
expect(node.height).toBe(100)
expect(node.fills.length).toBe(1)
expect(node.fills[0]!.type).toBe('SOLID')
expect(expectDefined(node.fills[0], 'first fill').type).toBe('SOLID')
})
it('renders text node with content', async () => {
@ -94,7 +95,7 @@ describe('renderTree', () => {
})
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.type).toBe('TEXT')
expect(node.text).toBe('Hello')
expect(node.fontSize).toBe(24)
@ -119,14 +120,14 @@ describe('renderTree', () => {
})
const result = await renderTree(g, tree)
const card = g.nodes.get(result.id)!
const card = getNodeOrThrow(g, result.id)
expect(card.layoutMode).toBe('VERTICAL')
expect(card.itemSpacing).toBe(16)
expect(card.paddingTop).toBe(24)
expect(card.paddingRight).toBe(24)
expect(card.childIds.length).toBe(3)
const title = g.nodes.get(card.childIds[1]!)!
const title = getNodeOrThrow(g, childIdAt(card, 1))
expect(title.text).toBe('Card Title')
expect(title.fontWeight).toBe(700)
})
@ -136,14 +137,14 @@ describe('renderTree', () => {
const tree = Frame({ name: 'Positioned', w: 100, h: 100 })
const result = await renderTree(g, tree, { x: 50, y: 75 })
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.x).toBe(50)
expect(node.y).toBe(75)
})
it('renders into a specific parent', async () => {
const g = makeSceneGraph()
const page = g.getPages()[0]!
const page = expectDefined(g.getPages()[0], 'first page')
const container = g.createNode('FRAME', page.id, { name: 'Container' })
const tree = Frame({ name: 'Child', w: 50, h: 50 })
@ -163,7 +164,7 @@ describe('renderTree', () => {
wrap: true
})
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.layoutMode).toBe('HORIZONTAL')
expect(node.itemSpacing).toBe(8)
@ -179,7 +180,7 @@ describe('renderTree', () => {
Text({ size: 16, color: '#FFFFFF', children: '★' })
)
const result = await renderTree(g, tree)
const n = g.nodes.get(result.id)!
const n = getNodeOrThrow(g, result.id)
expect(n.layoutMode).toBe('VERTICAL')
expect(n.primaryAxisAlign).toBe('CENTER')
@ -190,7 +191,7 @@ describe('renderTree', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Padded', px: 16, py: 8, pt: 4 })
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.paddingLeft).toBe(16)
expect(node.paddingRight).toBe(16)
@ -202,14 +203,14 @@ describe('renderTree', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Rounded', rounded: 12 })
const result = await renderTree(g, tree)
expect(g.nodes.get(result.id)!.cornerRadius).toBe(12)
expect(getNodeOrThrow(g, result.id).cornerRadius).toBe(12)
})
it('handles independent corners', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Corners', roundedTL: 8, roundedBR: 16 })
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.independentCorners).toBe(true)
expect(node.topLeftRadius).toBe(8)
@ -220,17 +221,17 @@ describe('renderTree', () => {
const g = makeSceneGraph()
const tree = Rectangle({ name: 'Bordered', stroke: '#000', strokeWidth: 2 })
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.strokes.length).toBe(1)
expect(node.strokes[0]!.weight).toBe(2)
expect(expectDefined(node.strokes[0], 'first stroke').weight).toBe(2)
})
it('handles opacity and rotation', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Transformed', opacity: 0.5, rotate: 45 })
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.opacity).toBe(0.5)
expect(node.rotation).toBe(45)
@ -240,14 +241,14 @@ describe('renderTree', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Clipped', overflow: 'hidden' })
const result = await renderTree(g, tree)
expect(g.nodes.get(result.id)!.clipsContent).toBe(true)
expect(getNodeOrThrow(g, result.id).clipsContent).toBe(true)
})
it('handles hug sizing', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Hug', w: 'hug', h: 'hug', flex: 'col' })
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.primaryAxisSizing).toBe('HUG')
expect(node.counterAxisSizing).toBe('HUG')
@ -257,7 +258,7 @@ describe('renderTree', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Fill', w: 'fill' })
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.layoutGrow).toBe(1)
})
@ -266,22 +267,22 @@ describe('renderTree', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Shadow', shadow: '0 4 12 rgba(0,0,0,0.1)' })
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.effects.length).toBe(1)
expect(node.effects[0]!.type).toBe('DROP_SHADOW')
expect(node.effects[0]!.radius).toBe(12)
expect(expectDefined(node.effects[0], 'first effect').type).toBe('DROP_SHADOW')
expect(expectDefined(node.effects[0], 'first effect').radius).toBe(12)
})
it('handles blur effect', async () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Blurred', blur: 8 })
const result = await renderTree(g, tree)
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.effects.length).toBe(1)
expect(node.effects[0]!.type).toBe('LAYER_BLUR')
expect(node.effects[0]!.radius).toBe(8)
expect(expectDefined(node.effects[0], 'first effect').type).toBe('LAYER_BLUR')
expect(expectDefined(node.effects[0], 'first effect').radius).toBe(8)
})
it('renders all primitive types', async () => {
@ -298,7 +299,7 @@ describe('renderTree', () => {
for (const { fn, expected } of types) {
const tree = fn({ name: expected })
const result = await renderTree(g, tree)
expect(g.nodes.get(result.id)!.type).toBe(expected)
expect(getNodeOrThrow(g, result.id).type).toBe(expected)
}
})
@ -322,9 +323,9 @@ describe('renderTreeNode', () => {
const result = await renderTreeNode(g, tree)
expect(result.name).toBe('FromAI')
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.childIds.length).toBe(1)
const label = g.nodes.get(node.childIds[0]!)!
const label = getNodeOrThrow(g, childIdAt(node, 0))
expect(label.text).toBe('Button')
})
})
@ -340,11 +341,11 @@ describe('renderJSX (string → scene graph)', () => {
const [result] = await renderJSX(g, jsx)
expect(result.name).toBe('Test')
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.type).toBe('FRAME')
expect(node.childIds.length).toBe(1)
const text = g.nodes.get(node.childIds[0]!)!
const text = getNodeOrThrow(g, childIdAt(node, 0))
expect(text.text).toBe('World')
})
@ -358,7 +359,7 @@ describe('renderJSX (string → scene graph)', () => {
</Frame>
`
const [result] = await renderJSX(g, jsx)
const card = g.nodes.get(result.id)!
const card = getNodeOrThrow(g, result.id)
expect(card.layoutMode).toBe('VERTICAL')
expect(card.childIds.length).toBe(3)
@ -367,7 +368,7 @@ describe('renderJSX (string → scene graph)', () => {
it('renders with position', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Frame name="At" w={50} h={50} />', { x: 100, y: 200 })
const node = g.nodes.get(result.id)!
const node = getNodeOrThrow(g, result.id)
expect(node.x).toBe(100)
expect(node.y).toBe(200)
@ -384,7 +385,7 @@ describe('grid layout rendering', () => {
Rectangle({ name: 'C', w: 50, h: 50 })
)
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.layoutMode).toBe('GRID')
expect(frame.gridTemplateColumns).toEqual([
@ -398,7 +399,7 @@ describe('grid layout rendering', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Grid', w: 400, h: 200, grid: true, columns: '100 1fr 2fr' })
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.gridTemplateColumns).toEqual([
{ sizing: 'FIXED', value: 100 },
@ -411,7 +412,7 @@ describe('grid layout rendering', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Grid', w: 300, h: 200, grid: true, columns: 3 })
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.gridTemplateColumns).toEqual([
{ sizing: 'FR', value: 1 },
@ -432,7 +433,7 @@ describe('grid layout rendering', () => {
rowGap: 20
})
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.gridColumnGap).toBe(10)
expect(frame.gridRowGap).toBe(20)
@ -442,7 +443,7 @@ describe('grid layout rendering', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Grid', w: 300, h: 200, grid: true, columns: '1fr 1fr', gap: 16 })
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.gridColumnGap).toBe(16)
expect(frame.gridRowGap).toBe(16)
@ -452,7 +453,7 @@ describe('grid layout rendering', () => {
const g = makeSceneGraph()
const tree = Frame({ name: 'Grid', w: 300, h: 200, grid: true, columns: '1fr 1fr', p: 10 })
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.paddingTop).toBe(10)
expect(frame.paddingRight).toBe(10)
@ -471,7 +472,7 @@ describe('grid layout rendering', () => {
rows: '100 1fr'
})
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.gridTemplateRows).toEqual([
{ sizing: 'FIXED', value: 100 },
@ -552,7 +553,7 @@ describe('grid layout rendering', () => {
</Frame>
`
const [result] = await renderJSX(g, jsx)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.layoutMode).toBe('GRID')
expect(frame.gridTemplateColumns).toEqual([
@ -578,11 +579,11 @@ describe('grid layout rendering', () => {
Rectangle({ name: 'D', w: 50, h: 60 })
)
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.gridTemplateRows).toEqual([])
computeLayout(g, result.id)
const updated = g.nodes.get(result.id)!
const updated = getNodeOrThrow(g, result.id)
expect(updated.height).toBe(150)
})
@ -623,7 +624,7 @@ describe('grid layout rendering', () => {
Rectangle({ name: 'B', w: 50, h: 50 })
)
const result = await renderTree(g, tree)
const frame = g.nodes.get(result.id)!
const frame = getNodeOrThrow(g, result.id)
expect(frame.layoutMode).toBe('GRID')
})
@ -639,7 +640,10 @@ describe('grid layout rendering', () => {
`
const [result] = await renderJSX(g, jsx)
computeAllLayouts(g)
const grid = g.getChildren(result.id).find((c) => c.name === 'G')!
const grid = expectDefined(
g.getChildren(result.id).find((c) => c.name === 'G'),
'grid node'
)
expect(grid.width).toBe(360)
expect(grid.layoutMode).toBe('GRID')
})
@ -657,7 +661,10 @@ describe('grid layout rendering', () => {
`
const [result] = await renderJSX(g, jsx)
computeAllLayouts(g)
const grid = g.getChildren(result.id).find((c) => c.name === 'G')!
const grid = expectDefined(
g.getChildren(result.id).find((c) => c.name === 'G'),
'grid node'
)
expect(grid.width).toBe(350)
})
@ -676,8 +683,14 @@ describe('grid layout rendering', () => {
`
const [result] = await renderJSX(g, jsx)
computeAllLayouts(g)
const content = g.getChildren(result.id).find((c) => c.name === 'Content')!
const grid = g.getChildren(content.id).find((c) => c.name === 'G')!
const content = expectDefined(
g.getChildren(result.id).find((c) => c.name === 'Content'),
'content node'
)
const grid = expectDefined(
g.getChildren(content.id).find((c) => c.name === 'G'),
'grid node'
)
expect(content.width).toBe(780)
expect(grid.width).toBe(724)
})
@ -695,7 +708,10 @@ describe('grid layout rendering', () => {
`
const [result] = await renderJSX(g, jsx)
computeAllLayouts(g)
const grid = g.getChildren(result.id).find((c) => c.name === 'G')!
const grid = expectDefined(
g.getChildren(result.id).find((c) => c.name === 'G'),
'grid node'
)
expect(grid.height).toBe(460)
})
})
@ -704,7 +720,7 @@ describe('text props round-trip', () => {
it('lineHeight renders and exports', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" lineHeight={24}>Hello</Text>')
const n = g.getNode(result.id)!
const n = getNodeOrThrow(g, result.id)
expect(n.lineHeight).toBe(24)
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('lineHeight={24}')
@ -713,7 +729,7 @@ describe('text props round-trip', () => {
it('letterSpacing renders and exports', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" letterSpacing={2}>Spaced</Text>')
const n = g.getNode(result.id)!
const n = getNodeOrThrow(g, result.id)
expect(n.letterSpacing).toBe(2)
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('letterSpacing={2}')
@ -722,7 +738,7 @@ describe('text props round-trip', () => {
it('textDecoration renders and exports', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" textDecoration="underline">Link</Text>')
const n = g.getNode(result.id)!
const n = getNodeOrThrow(g, result.id)
expect(n.textDecoration).toBe('UNDERLINE')
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('textDecoration="underline"')
@ -731,7 +747,7 @@ describe('text props round-trip', () => {
it('textCase renders and exports', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" textCase="upper">label</Text>')
const n = g.getNode(result.id)!
const n = getNodeOrThrow(g, result.id)
expect(n.textCase).toBe('UPPER')
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('textCase="upper"')
@ -740,7 +756,7 @@ describe('text props round-trip', () => {
it('maxLines renders with truncation', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" maxLines={2}>Long text here</Text>')
const n = g.getNode(result.id)!
const n = getNodeOrThrow(g, result.id)
expect(n.maxLines).toBe(2)
expect(n.textTruncation).toBe('ENDING')
const jsx = sceneNodeToJSX(n.id, g)
@ -750,7 +766,7 @@ describe('text props round-trip', () => {
it('truncate without maxLines', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000" truncate>Overflow</Text>')
const n = g.getNode(result.id)!
const n = getNodeOrThrow(g, result.id)
expect(n.textTruncation).toBe('ENDING')
const jsx = sceneNodeToJSX(n.id, g)
expect(jsx).toContain('truncate')
@ -759,7 +775,7 @@ describe('text props round-trip', () => {
it('defaults omit text props', async () => {
const g = makeSceneGraph()
const [result] = await renderJSX(g, '<Text color="#000">Plain</Text>')
const n = g.getNode(result.id)!
const n = getNodeOrThrow(g, result.id)
expect(n.lineHeight).toBeNull()
expect(n.letterSpacing).toBe(0)
expect(n.textDecoration).toBe('NONE')
@ -784,8 +800,8 @@ describe('text props round-trip', () => {
</Frame>
`
)
const card = g.getNode(result.id)!
const title = g.getNode(card.childIds[0]!)!
const card = getNodeOrThrow(g, result.id)
const title = getNodeOrThrow(g, childIdAt(card, 0))
expect(title.layoutAlignSelf).toBe('STRETCH')
expect(title.textAutoResize).toBe('HEIGHT')
const jsx = sceneNodeToJSX(title.id, g)
@ -804,8 +820,8 @@ describe('text props round-trip', () => {
</Frame>
`
)
const row = g.getNode(result.id)!
const value = g.getNode(row.childIds[1]!)!
const row = getNodeOrThrow(g, result.id)
const value = getNodeOrThrow(g, childIdAt(row, 1))
expect(value.layoutGrow).toBe(1)
const jsx = sceneNodeToJSX(value.id, g)
expect(jsx).toContain('grow={1}')

View file

@ -3,6 +3,8 @@ import { describe, test, expect } from 'bun:test'
import { SceneGraph, TextEditor, UndoManager } from '@open-pencil/core'
import { createTextActions } from '@open-pencil/core/editor'
import { expectDefined, getNodeOrThrow } from '../helpers/assert'
import type { StyleRun } from '@open-pencil/core'
import type { EditorContext, EditorState } from '@open-pencil/core/editor'
import type { CanvasKit } from 'canvaskit-wasm'
@ -59,19 +61,21 @@ describe('text edit undo', () => {
expect(textEditor.isActive).toBe(true)
textEditor.insert(' World', textNode)
graph.updateNode(textNode.id, { text: textEditor.state!.text })
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
actions.commitTextEdit()
expect(undo.canUndo).toBe(true)
expect(undo.undoLabel).toBe('Edit text')
expect(graph.getNode(textNode.id)!.text).toBe('Hello World')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello World')
undo.undo()
expect(graph.getNode(textNode.id)!.text).toBe('Hello')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello')
undo.redo()
expect(graph.getNode(textNode.id)!.text).toBe('Hello World')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello World')
})
test('commitTextEdit does not push undo when text unchanged', () => {
@ -89,18 +93,22 @@ describe('text edit undo', () => {
actions.startTextEditing(textNode.id)
textEditor.insert(' Beautiful', textNode)
graph.updateNode(textNode.id, { text: textEditor.state!.text })
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
textEditor.insert(' World', textNode)
graph.updateNode(textNode.id, { text: textEditor.state!.text })
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
actions.commitTextEdit()
expect(graph.getNode(textNode.id)!.text).toBe('Hello Beautiful World')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello Beautiful World')
expect(undo.canUndo).toBe(true)
undo.undo()
expect(graph.getNode(textNode.id)!.text).toBe('Hello')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello')
})
test('sequential edits create separate undo entries', () => {
@ -108,21 +116,25 @@ describe('text edit undo', () => {
actions.startTextEditing(textNode.id)
textEditor.insert('!', textNode)
graph.updateNode(textNode.id, { text: textEditor.state!.text })
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
actions.commitTextEdit()
actions.startTextEditing(textNode.id)
textEditor.insert('!', textNode)
graph.updateNode(textNode.id, { text: textEditor.state!.text })
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text
})
actions.commitTextEdit()
expect(graph.getNode(textNode.id)!.text).toBe('Hello!!')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello!!')
undo.undo()
expect(graph.getNode(textNode.id)!.text).toBe('Hello!')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello!')
undo.undo()
expect(graph.getNode(textNode.id)!.text).toBe('Hello')
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello')
})
test('undo restores styleRuns when they changed during editing', () => {
@ -138,20 +150,23 @@ describe('text edit undo', () => {
{ start: 0, length: 5, style: { fontWeight: 700 } },
{ start: 5, length: 6, style: { fontWeight: 400 } }
]
graph.updateNode(textNode.id, { text: textEditor.state!.text, styleRuns: newRuns })
graph.updateNode(textNode.id, {
text: expectDefined(textEditor.state, 'text editor state').text,
styleRuns: newRuns
})
actions.commitTextEdit()
expect(graph.getNode(textNode.id)!.styleRuns).toEqual(newRuns)
expect(getNodeOrThrow(graph, textNode.id).styleRuns).toEqual(newRuns)
expect(undo.canUndo).toBe(true)
undo.undo()
expect(graph.getNode(textNode.id)!.text).toBe('Hello')
expect(graph.getNode(textNode.id)!.styleRuns).toEqual([boldRun])
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello')
expect(getNodeOrThrow(graph, textNode.id).styleRuns).toEqual([boldRun])
undo.redo()
expect(graph.getNode(textNode.id)!.text).toBe('Hello World')
expect(graph.getNode(textNode.id)!.styleRuns).toEqual(newRuns)
expect(getNodeOrThrow(graph, textNode.id).text).toBe('Hello World')
expect(getNodeOrThrow(graph, textNode.id).styleRuns).toEqual(newRuns)
})
test('undo entry is pushed when only styleRuns changed', () => {
@ -167,7 +182,7 @@ describe('text edit undo', () => {
expect(undo.canUndo).toBe(true)
undo.undo()
expect(graph.getNode(textNode.id)!.styleRuns).toEqual([])
expect(getNodeOrThrow(graph, textNode.id).styleRuns).toEqual([])
})
test('no undo entry when neither text nor styleRuns changed', () => {

16
tests/helpers/assert.ts Normal file
View file

@ -0,0 +1,16 @@
import type { SceneGraph, SceneNode } from '@open-pencil/core'
export function expectDefined<T>(value: T | null | undefined, label = 'value'): NonNullable<T> {
if (value == null) {
throw new Error(`${label} was expected to be defined`)
}
return value
}
export function getNodeOrThrow(graph: SceneGraph, id: string): SceneNode {
return expectDefined(graph.getNode(id), `node ${id}`)
}
export function childIdAt(node: SceneNode, index: number): string {
return expectDefined(node.childIds[index], `child ${index} of ${node.id}`)
}