From 51eae14e4bc1fa73f645ab9a8eb9891aff3aa58b Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 6 May 2026 01:14:50 +0300 Subject: [PATCH] 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 --- oxlint.structure.json | 11 ++ packages/cli/src/commands/eval.ts | 7 +- packages/core/src/figma-api/index.ts | 16 ++- .../core/src/profiler/draw-call-counter.ts | 1 - packages/core/src/tools/analyze/eval.ts | 12 +- src/app/ai/chat/transports.ts | 13 +- src/global.d.ts | 3 +- tests/engine/fig-roundtrip.test.ts | 33 ++--- tests/engine/nudge.test.ts | 46 +++--- tests/engine/render.test.ts | 134 ++++++++++-------- tests/engine/text-edit-undo.test.ts | 55 ++++--- tests/helpers/assert.ts | 16 +++ 12 files changed, 210 insertions(+), 137 deletions(-) create mode 100644 tests/helpers/assert.ts diff --git a/oxlint.structure.json b/oxlint.structure.json index ce243fe0c..27a7430bb 100644 --- a/oxlint.structure.json +++ b/oxlint.structure.json @@ -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": { diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts index c5c93dc8e..de574348c 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -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 + const AsyncFunction = Object.getPrototypeOf(async () => undefined) + .constructor as AsyncFunctionConstructor const wrappedCode = code.trim().startsWith('return') ? code : `return (async () => { ${code} })()` diff --git a/packages/core/src/figma-api/index.ts b/packages/core/src/figma-api/index.ts index b762174a5..233955e0d 100644 --- a/packages/core/src/figma-api/index.ts +++ b/packages/core/src/figma-api/index.ts @@ -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[], diff --git a/packages/core/src/profiler/draw-call-counter.ts b/packages/core/src/profiler/draw-call-counter.ts index 471d3e3d4..354d40bd4 100644 --- a/packages/core/src/profiler/draw-call-counter.ts +++ b/packages/core/src/profiler/draw-call-counter.ts @@ -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) diff --git a/packages/core/src/tools/analyze/eval.ts b/packages/core/src/tools/analyze/eval.ts index a219b23c7..4aafdc9b5 100644 --- a/packages/core/src/tools/analyze/eval.ts +++ b/packages/core/src/tools/analyze/eval.ts @@ -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 + 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)' } } diff --git a/src/app/ai/chat/transports.ts b/src/app/ai/chat/transports.ts index e02257e8d..53302b070 100644 --- a/src/app/ai/chat/transports.ts +++ b/src/app/ai/chat/transports.ts @@ -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 @@ -110,7 +110,7 @@ export function createToolLoopTransport({ } }) - return new DirectChatTransport({ agent }) + return new DirectChatTransport({ agent }) as ChatTransport } export function createChatSessionManager({ @@ -130,8 +130,7 @@ export function createChatSessionManager({ let currentChatMessages = new WeakMap() let chat: Chat | null = null let acpTransportInstance: { destroy(): Promise } | 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) | 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 } 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 = isACPProvider.value ? await createActiveACPTransport() : createTransport(store) chat = new Chat({ transport, messages }) @@ -191,7 +190,7 @@ export function createChatSessionManager({ transportDirty = false } - function setOverrideTransport(factory: (() => unknown) | null) { + function setOverrideTransport(factory: (() => ChatTransport) | null) { overrideTransport = factory markTransportDirty() } diff --git a/src/global.d.ts b/src/global.d.ts index d833b9d92..4bf0f813b 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -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 }[] > - __OPEN_PENCIL_SET_TRANSPORT__?(factory: () => unknown): void + __OPEN_PENCIL_SET_TRANSPORT__?(factory: () => ChatTransport): void __OPEN_PENCIL_STORE__?: EditorStore __TEST_WRITE_COUNT__?(): number __TEST_MOCK_HANDLE__?: FileSystemFileHandle diff --git a/tests/engine/fig-roundtrip.test.ts b/tests/engine/fig-roundtrip.test.ts index 4d440d89e..2770e710e 100644 --- a/tests/engine/fig-roundtrip.test.ts +++ b/tests/engine/fig-roundtrip.test.ts @@ -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') }) diff --git a/tests/engine/nudge.test.ts b/tests/engine/nudge.test.ts index 7440994a5..6b01d1952 100644 --- a/tests/engine/nudge.test.ts +++ b/tests/engine/nudge.test.ts @@ -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) }) }) diff --git a/tests/engine/render.test.ts b/tests/engine/render.test.ts index d7074ffc7..afafbdc0a 100644 --- a/tests/engine/render.test.ts +++ b/tests/engine/render.test.ts @@ -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 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)', () => { ` 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, '', { 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', () => { ` 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, 'Hello') - 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, 'Spaced') - 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, 'Link') - 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, 'label') - 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, 'Long text here') - 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, 'Overflow') - 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, 'Plain') - 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', () => { ` ) - 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', () => { ` ) - 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}') diff --git a/tests/engine/text-edit-undo.test.ts b/tests/engine/text-edit-undo.test.ts index f03ffcac7..2e6ab864c 100644 --- a/tests/engine/text-edit-undo.test.ts +++ b/tests/engine/text-edit-undo.test.ts @@ -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', () => { diff --git a/tests/helpers/assert.ts b/tests/helpers/assert.ts new file mode 100644 index 000000000..db365bf3d --- /dev/null +++ b/tests/helpers/assert.ts @@ -0,0 +1,16 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/core' + +export function expectDefined(value: T | null | undefined, label = 'value'): NonNullable { + 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}`) +}