chore(tests): enforce non-null assertion rule

- Remove remaining test non-null assertions and delete the broad test override
- Add explicit guards for canvas, design panel, auto-layout, SVG, Kiwi, hit-test, scene graph, text editor, and clipboard tests
- Validate check plus affected engine and E2E coverage
This commit is contained in:
Danila Poyarkov 2026-05-06 11:44:18 +03:00
parent 17857f912f
commit 657188b097
12 changed files with 430 additions and 271 deletions

View file

@ -162,12 +162,6 @@
"open-pencil/no-useless-pass-through-wrappers": "error"
}
},
{
"files": ["tests/**/*.ts", "tests/**/*.tsx"],
"rules": {
"typescript/no-non-null-assertion": "off"
}
},
{
"files": ["**/kiwi/kiwi-schema/**"],
"rules": {

View file

@ -1,5 +1,6 @@
import { test, expect, type Page } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
import { getSelectedIds, getPageChildren, getSelectedNode, getNodeById } from '#tests/helpers/store'
@ -106,8 +107,8 @@ test('resize corner handle drag increases node dimensions', async () => {
const box = await page.locator('[data-test-id="canvas-element"]').boundingBox()
if (!box) throw new Error('No canvas')
const hx = box.x + viewport!.handleX
const hy = box.y + viewport!.handleY
const hx = box.x + expectDefined(viewport, 'viewport state').handleX
const hy = box.y + expectDefined(viewport, 'viewport state').handleY
await page.mouse.move(hx, hy)
await page.mouse.down()
@ -116,8 +117,12 @@ test('resize corner handle drag increases node dimensions', async () => {
await canvas.waitForRender()
const after = await getSelectedNode(page)
expect(after!.width).toBeGreaterThan(before!.width + 20)
expect(after!.height).toBeGreaterThan(before!.height + 20)
expect(expectDefined(after, 'selected node after').width).toBeGreaterThan(
expectDefined(before, 'selected node before').width + 20
)
expect(expectDefined(after, 'selected node after').height).toBeGreaterThan(
expectDefined(before, 'selected node before').height + 20
)
canvas.assertNoErrors()
})
@ -129,7 +134,7 @@ test('rotation handle drag rotates node', async () => {
const before = await getSelectedNode(page)
expect(before).not.toBeNull()
const initialRotation = before!.rotation ?? 0
const initialRotation = expectDefined(before, 'selected node before').rotation ?? 0
const viewport = await page.evaluate(() => {
const store = window.openPencil?.store
@ -150,8 +155,8 @@ test('rotation handle drag rotates node', async () => {
const box = await page.locator('[data-test-id="canvas-element"]').boundingBox()
if (!box) throw new Error('No canvas')
const rx = box.x + viewport!.cx
const ry = box.y + viewport!.topMidY - 24
const rx = box.x + expectDefined(viewport, 'viewport state').cx
const ry = box.y + expectDefined(viewport, 'viewport state').topMidY - 24
await page.mouse.move(rx, ry)
await canvas.waitForRender()
@ -160,8 +165,8 @@ test('rotation handle drag rotates node', async () => {
await page.mouse.up()
await canvas.waitForRender()
const after = await getNodeById(page, before!.id)
expect(after!.rotation ?? 0).not.toBe(initialRotation)
const after = await getNodeById(page, expectDefined(before, 'selected node before').id)
expect(expectDefined(after, 'selected node after').rotation ?? 0).not.toBe(initialRotation)
canvas.assertNoErrors()
})
@ -236,14 +241,20 @@ async function setupFrameChild(rotation: number) {
})
expect(state).not.toBeNull()
return state!
return expectDefined(state, 'frame child state')
}
test('frame children keep correct hover and click hit area without rotation', async () => {
const state = await setupFrameChild(0)
await canvas.hover(state.hitX, state.hitY)
const hoveredId = await page.evaluate(() => window.openPencil?.store!.state.hoveredNodeId)
const hoveredId = await page.evaluate(() =>
(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.hoveredNodeId
})()
)
expect(hoveredId).toBe(state.childId)
await canvas.click(state.hitX, state.hitY)
@ -252,7 +263,13 @@ test('frame children keep correct hover and click hit area without rotation', as
expect(selected?.id).toBe(state.childId)
await canvas.hover(state.missX, state.missY)
const hoveredMiss = await page.evaluate(() => window.openPencil?.store!.state.hoveredNodeId)
const hoveredMiss = await page.evaluate(() =>
(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.hoveredNodeId
})()
)
expect(hoveredMiss).not.toBe(state.childId)
canvas.assertNoErrors()
})
@ -261,7 +278,13 @@ test('rotated frame children keep correct hover and click hit area', async () =>
const state = await setupFrameChild(35)
await canvas.hover(state.hitX, state.hitY)
const hoveredId = await page.evaluate(() => window.openPencil?.store!.state.hoveredNodeId)
const hoveredId = await page.evaluate(() =>
(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.hoveredNodeId
})()
)
expect(hoveredId).toBe(state.childId)
await canvas.click(state.hitX, state.hitY)
@ -270,7 +293,13 @@ test('rotated frame children keep correct hover and click hit area', async () =>
expect(selected?.id).toBe(state.childId)
await canvas.hover(state.missX, state.missY)
const hoveredMiss = await page.evaluate(() => window.openPencil?.store!.state.hoveredNodeId)
const hoveredMiss = await page.evaluate(() =>
(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.hoveredNodeId
})()
)
expect(hoveredMiss).not.toBe(state.childId)
canvas.assertNoErrors()
})
@ -300,8 +329,8 @@ test('rotation drag exposes live rotation preview state', async () => {
const box = await page.locator('[data-test-id="canvas-element"]').boundingBox()
if (!box) throw new Error('No canvas')
const rx = box.x + viewport!.cx
const ry = box.y + viewport!.topMidY - 24
const rx = box.x + expectDefined(viewport, 'viewport state').cx
const ry = box.y + expectDefined(viewport, 'viewport state').topMidY - 24
await page.mouse.move(rx, ry)
await canvas.waitForRender()
@ -309,13 +338,25 @@ test('rotation drag exposes live rotation preview state', async () => {
await page.mouse.move(rx + 60, ry + 60, { steps: 15 })
await canvas.waitForRender()
const preview = await page.evaluate(() => window.openPencil?.store!.state.rotationPreview)
const preview = await page.evaluate(() =>
(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.rotationPreview
})()
)
expect(preview).not.toBeNull()
await page.mouse.up()
await canvas.waitForRender()
const clearedPreview = await page.evaluate(() => window.openPencil?.store!.state.rotationPreview)
const clearedPreview = await page.evaluate(() =>
(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
return store.state.rotationPreview
})()
)
expect(clearedPreview).toBeNull()
canvas.assertNoErrors()
})

View file

@ -1,5 +1,6 @@
import { expect, test, type Page } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
let page: Page
@ -102,7 +103,7 @@ test('fill item shows color swatch', async () => {
test('clicking color area changes fill color', async () => {
const id = await getSelectedId()
const before = await getNode(id!)
const before = await getNode(expectDefined(id, 'selected id'))
const swatch = fillSection().locator('[data-test-id="fill-picker-swatch"]').first()
await swatch.click()
@ -111,13 +112,16 @@ test('clicking color area changes fill color', async () => {
await expect(colorArea).toBeVisible({ timeout: 5000 })
const box = await colorArea.boundingBox()
await page.mouse.click(box!.x + box!.width - 10, box!.y + 10)
await page.mouse.click(
expectDefined(box, 'color area bounds').x + expectDefined(box, 'color area bounds').width - 10,
expectDefined(box, 'color area bounds').y + 10
)
await canvas.waitForRender()
await page.waitForTimeout(100)
const after = await getNode(id!)
const c1 = before!.fills[0].color
const c2 = after!.fills[0].color
const after = await getNode(expectDefined(id, 'selected id'))
const c1 = expectDefined(before, 'before node').fills[0].color
const c2 = expectDefined(after, 'after node').fills[0].color
expect(c1.r !== c2.r || c1.g !== c2.g || c1.b !== c2.b).toBe(true)
// Close popover — click the swatch again to toggle it off
@ -134,8 +138,8 @@ test('adding a stroke creates stroke section item', async () => {
await expect(strokeItems.first()).toBeVisible()
const id = await getSelectedId()
const node = await getNode(id!)
expect(node!.strokes.length).toBe(1)
const node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node node').strokes.length).toBe(1)
})
test('adding an effect creates effect item', async () => {
@ -147,8 +151,8 @@ test('adding an effect creates effect item', async () => {
await expect(effectItems.first()).toBeVisible()
const id = await getSelectedId()
const node = await getNode(id!)
expect(node!.effects.length).toBe(1)
const node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node node').effects.length).toBe(1)
})
test('adding a second fill shows two fill items', async () => {
@ -160,8 +164,8 @@ test('adding a second fill shows two fill items', async () => {
expect(await fillItems.count()).toBe(2)
const id = await getSelectedId()
const node = await getNode(id!)
expect(node!.fills.length).toBe(2)
const node = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(node, 'node node').fills.length).toBe(2)
})
test('visibility toggle in appearance section works', async () => {
@ -169,20 +173,20 @@ test('visibility toggle in appearance section works', async () => {
await expect(visBtn).toBeVisible()
const id = await getSelectedId()
const before = await getNode(id!)
expect(before!.visible).toBe(true)
const before = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(before, 'before node').visible).toBe(true)
await visBtn.click()
await canvas.waitForRender()
const after = await getNode(id!)
expect(after!.visible).toBe(false)
const after = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(after, 'after node').visible).toBe(false)
await visBtn.click()
await canvas.waitForRender()
const restored = await getNode(id!)
expect(restored!.visible).toBe(true)
const restored = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(restored, 'restored node').visible).toBe(true)
})
test('fill stroke and effect visibility toggles update on repeated clicks and support undo redo', async () => {
@ -192,23 +196,35 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
const fillButton = page.locator('[data-test-id="fill-visibility-0"]')
await expect(fillButton).toBeVisible()
const initial = await getNode(id!)
expect(initial!.fills[0]?.visible).toBe(true)
const initial = await getNode(expectDefined(id, 'selected id'))
expect(expectDefined(initial, 'initial node').fills[0]?.visible).toBe(true)
await fillButton.click()
await canvas.waitForRender()
await expect(fillButton).toHaveAttribute('data-visible', 'false')
expect((await getNode(id!))!.fills[0]?.visible).toBe(false)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').fills[0]
?.visible
).toBe(false)
await fillButton.click()
await canvas.waitForRender()
await expect(fillButton).toHaveAttribute('data-visible', 'true')
expect((await getNode(id!))!.fills[0]?.visible).toBe(true)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').fills[0]
?.visible
).toBe(true)
await canvas.undo()
expect((await getNode(id!))!.fills[0]?.visible).toBe(false)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').fills[0]
?.visible
).toBe(false)
await canvas.redo()
expect((await getNode(id!))!.fills[0]?.visible).toBe(true)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').fills[0]
?.visible
).toBe(true)
const strokeAddButton = strokeSection().locator('[data-test-id="stroke-section-add"]')
await strokeAddButton.click()
@ -216,22 +232,37 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]')
await expect(strokeButton).toBeVisible()
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').strokes[0]
?.visible
).toBe(true)
await strokeButton.click()
await canvas.waitForRender()
await expect(strokeButton).toHaveAttribute('data-visible', 'false')
expect((await getNode(id!))!.strokes[0]?.visible).toBe(false)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').strokes[0]
?.visible
).toBe(false)
await strokeButton.click()
await canvas.waitForRender()
await expect(strokeButton).toHaveAttribute('data-visible', 'true')
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').strokes[0]
?.visible
).toBe(true)
await canvas.undo()
expect((await getNode(id!))!.strokes[0]?.visible).toBe(false)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').strokes[0]
?.visible
).toBe(false)
await canvas.redo()
expect((await getNode(id!))!.strokes[0]?.visible).toBe(true)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').strokes[0]
?.visible
).toBe(true)
const effectAddButton = effectsSection().locator('[data-test-id="effects-section-add"]')
await effectAddButton.click()
@ -239,22 +270,37 @@ test('fill stroke and effect visibility toggles update on repeated clicks and su
const effectButton = page.locator('[data-test-id="effect-visibility-0"]')
await expect(effectButton).toBeVisible()
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').effects[0]
?.visible
).toBe(true)
await effectButton.click()
await canvas.waitForRender()
await expect(effectButton).toHaveAttribute('data-visible', 'false')
expect((await getNode(id!))!.effects[0]?.visible).toBe(false)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').effects[0]
?.visible
).toBe(false)
await effectButton.click()
await canvas.waitForRender()
await expect(effectButton).toHaveAttribute('data-visible', 'true')
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').effects[0]
?.visible
).toBe(true)
await canvas.undo()
expect((await getNode(id!))!.effects[0]?.visible).toBe(false)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').effects[0]
?.visible
).toBe(false)
await canvas.redo()
expect((await getNode(id!))!.effects[0]?.visible).toBe(true)
expect(
expectDefined(await getNode(expectDefined(id, 'selected id')), 'selected node').effects[0]
?.visible
).toBe(true)
})
test('deselecting shows empty design panel', async () => {

View file

@ -1,5 +1,6 @@
import { test, expect, type Page } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas'
import { getSelectedNode, getNodeById } from '#tests/helpers/store'
@ -24,7 +25,9 @@ test.afterAll(async () => {
async function selectFrame() {
expect(frameId, 'frameId must be set — did the Shift+A test run?').toBeTruthy()
await page.evaluate((id: string) => {
window.openPencil?.store!.select([id])
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, frameId)
await canvas.waitForRender()
}
@ -41,11 +44,11 @@ test('Shift+A wraps selection in auto-layout frame', async () => {
const node = await getSelectedNode(page)
expect(node).not.toBeNull()
expect(node!.type).toBe('FRAME')
expect(node!.layoutMode).not.toBe('NONE')
expect(node!.childIds.length).toBe(2)
expect(expectDefined(node, 'node').type).toBe('FRAME')
expect(expectDefined(node, 'node').layoutMode).not.toBe('NONE')
expect(expectDefined(node, 'node').childIds.length).toBe(2)
frameId = node!.id
frameId = expectDefined(node, 'node').id
canvas.assertNoErrors()
})
@ -56,19 +59,19 @@ test('direction button toggles to VERTICAL', async () => {
await canvas.waitForRender()
const frame = await getNodeById(page, frameId)
expect(frame!.layoutMode).toBe('VERTICAL')
expect(expectDefined(frame, 'frame').layoutMode).toBe('VERTICAL')
canvas.assertNoErrors()
})
test('gap ScrubInput sets itemSpacing', async () => {
await selectFrame()
const before = await getNodeById(page, frameId)
const initialSpacing = before!.itemSpacing
const initialSpacing = expectDefined(before, 'before').itemSpacing
await canvas.dragScrubInput(page.locator('[data-test-id="layout-gap-input"]'), 40)
const after = await getNodeById(page, frameId)
expect(after!.itemSpacing).toBeGreaterThan(initialSpacing + 5)
expect(expectDefined(after, 'after').itemSpacing).toBeGreaterThan(initialSpacing + 5)
canvas.assertNoErrors()
})
@ -79,15 +82,17 @@ test('gap menu sets auto space-between alignment', async () => {
await page.getByRole('option', { name: 'Auto' }).click()
await canvas.waitForRender()
let frame = await getNodeById(page, frameId)
expect(frame!.primaryAxisAlign).toBe('SPACE_BETWEEN')
expect(expectDefined(frame, 'frame').primaryAxisAlign).toBe('SPACE_BETWEEN')
await expect(page.locator('[data-test-id="layout-alignment-grid"] button')).toHaveCount(9)
await page.locator('[data-test-id="layout-gap-menu"]').click()
await page.getByRole('option', { name: String(Math.round(frame!.itemSpacing)) }).click()
await page
.getByRole('option', { name: String(Math.round(expectDefined(frame, 'frame').itemSpacing)) })
.click()
await canvas.waitForRender()
frame = await getNodeById(page, frameId)
expect(frame!.primaryAxisAlign).toBe('MIN')
expect(expectDefined(frame, 'frame').primaryAxisAlign).toBe('MIN')
await expect(page.locator('[data-test-id="layout-alignment-grid"] button')).toHaveCount(9)
canvas.assertNoErrors()
})
@ -99,12 +104,12 @@ test('wrap mode exposes cross-axis gap control', async () => {
await canvas.waitForRender()
const before = await getNodeById(page, frameId)
const initialSpacing = before!.counterAxisSpacing
const initialSpacing = expectDefined(before, 'before').counterAxisSpacing
await canvas.dragScrubInput(page.locator('[data-test-id="layout-cross-gap-input"]'), 40)
const after = await getNodeById(page, frameId)
expect(after!.layoutWrap).toBe('WRAP')
expect(after!.counterAxisSpacing).toBeGreaterThan(initialSpacing + 5)
expect(expectDefined(after, 'after').layoutWrap).toBe('WRAP')
expect(expectDefined(after, 'after').counterAxisSpacing).toBeGreaterThan(initialSpacing + 5)
canvas.assertNoErrors()
})
@ -130,10 +135,10 @@ test('padding controls set horizontal and vertical padding pairs', async () => {
await canvas.waitForRender()
const frame = await getNodeById(page, frameId)
expect(frame!.paddingTop).toBe(16)
expect(frame!.paddingRight).toBe(24)
expect(frame!.paddingBottom).toBe(16)
expect(frame!.paddingLeft).toBe(24)
expect(expectDefined(frame, 'frame').paddingTop).toBe(16)
expect(expectDefined(frame, 'frame').paddingRight).toBe(24)
expect(expectDefined(frame, 'frame').paddingBottom).toBe(16)
expect(expectDefined(frame, 'frame').paddingLeft).toBe(24)
canvas.assertNoErrors()
})
@ -145,7 +150,9 @@ test('size dropdown adds and removes min width', async () => {
await canvas.waitForRender()
let frame = await getNodeById(page, frameId)
expect(frame!.minWidth).toBe(Math.round(frame!.width))
expect(expectDefined(frame, 'frame').minWidth).toBe(
Math.round(expectDefined(frame, 'frame').width)
)
await expect(page.locator('[data-test-id="layout-min-width-input"]')).toBeVisible()
await page.locator('[data-test-id="layout-width-sizing-menu"]').click()
@ -153,7 +160,7 @@ test('size dropdown adds and removes min width', async () => {
await canvas.waitForRender()
frame = await getNodeById(page, frameId)
expect(frame!.minWidth).toBeNull()
expect(expectDefined(frame, 'frame').minWidth).toBeNull()
await expect(page.locator('[data-test-id="layout-min-width-input"]')).toHaveCount(0)
canvas.assertNoErrors()
})
@ -166,8 +173,8 @@ test('alignment grid center sets CENTER alignment', async () => {
await canvas.waitForRender()
const frame = await getNodeById(page, frameId)
expect(frame!.primaryAxisAlign).toBe('CENTER')
expect(frame!.counterAxisAlign).toBe('CENTER')
expect(expectDefined(frame, 'frame').primaryAxisAlign).toBe('CENTER')
expect(expectDefined(frame, 'frame').counterAxisAlign).toBe('CENTER')
canvas.assertNoErrors()
})
@ -178,6 +185,6 @@ test('remove auto-layout sets layoutMode to NONE', async () => {
await canvas.waitForRender()
const frame = await getNodeById(page, frameId)
expect(frame!.layoutMode).toBe('NONE')
expect(expectDefined(frame, 'frame').layoutMode).toBe('NONE')
canvas.assertNoErrors()
})

View file

@ -3,6 +3,7 @@ import { describe, expect, it } from 'bun:test'
import { importClipboardNodes } from '@open-pencil/core'
import type { NodeChange, SceneNode } from '@open-pencil/core'
import { getNodeOrThrow } from '#tests/helpers/assert'
import { createClipboardGraph } from '#tests/helpers/clipboard'
describe('importClipboardNodes', () => {
@ -52,7 +53,7 @@ describe('importClipboardNodes', () => {
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const card = graph.getNode(created[0])!
const card = getNodeOrThrow(graph, created[0])
expect(card.type).toBe('FRAME')
expect(card.name).toBe('Card')
@ -109,7 +110,7 @@ describe('importClipboardNodes', () => {
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
expect(graph.getNode(created[0])!.name).toBe('RealShape')
expect(getNodeOrThrow(graph, created[0]).name).toBe('RealShape')
})
it('imports nested frames with children', () => {
@ -154,7 +155,7 @@ describe('importClipboardNodes', () => {
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const outer = graph.getNode(created[0])!
const outer = getNodeOrThrow(graph, created[0])
expect(outer.name).toBe('Outer')
const innerList = graph.getChildren(outer.id)
@ -196,7 +197,7 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const node = graph.getNode(created[0])!
const node = getNodeOrThrow(graph, created[0])
expect(node.fills).toHaveLength(1)
expect(node.fills[0].color.r).toBe(1)
expect(node.strokes).toHaveLength(1)
@ -244,7 +245,7 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const row = graph.getNode(created[0])!
const row = getNodeOrThrow(graph, created[0])
const children = graph.getChildren(row.id)
expect(children[0].layoutAlignSelf).toBe('STRETCH')
expect(children[1].layoutAlignSelf).toBe('AUTO')
@ -290,9 +291,9 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.clipsContent).toBe(true)
expect(graph.getNode(created[1])!.clipsContent).toBe(false)
expect(graph.getNode(created[2])!.clipsContent).toBe(false)
expect(getNodeOrThrow(graph, created[0]).clipsContent).toBe(true)
expect(getNodeOrThrow(graph, created[1]).clipsContent).toBe(false)
expect(getNodeOrThrow(graph, created[2]).clipsContent).toBe(false)
})
it('imports fontWeight from fontName.style via styleToWeight', () => {
@ -342,10 +343,10 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.fontWeight).toBe(500)
expect(graph.getNode(created[1])!.fontWeight).toBe(700)
expect(graph.getNode(created[2])!.fontWeight).toBe(700)
expect(graph.getNode(created[2])!.italic).toBe(true)
expect(getNodeOrThrow(graph, created[0]).fontWeight).toBe(500)
expect(getNodeOrThrow(graph, created[1]).fontWeight).toBe(700)
expect(getNodeOrThrow(graph, created[2]).fontWeight).toBe(700)
expect(getNodeOrThrow(graph, created[2]).italic).toBe(true)
})
it('converts letterSpacing object to pixels', () => {
@ -394,9 +395,9 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.letterSpacing).toBe(2)
expect(graph.getNode(created[1])!.letterSpacing).toBe(2) // 10% of 20px
expect(graph.getNode(created[2])!.letterSpacing).toBe(0)
expect(getNodeOrThrow(graph, created[0]).letterSpacing).toBe(2)
expect(getNodeOrThrow(graph, created[1]).letterSpacing).toBe(2) // 10% of 20px
expect(getNodeOrThrow(graph, created[2]).letterSpacing).toBe(0)
})
it('converts RAW lineHeight to pixels', () => {
@ -446,9 +447,9 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.lineHeight).toBe(36) // 24 * 1.5
expect(graph.getNode(created[1])!.lineHeight).toBe(20)
expect(graph.getNode(created[2])!.lineHeight).toBe(24) // 120% of 20
expect(getNodeOrThrow(graph, created[0]).lineHeight).toBe(36) // 24 * 1.5
expect(getNodeOrThrow(graph, created[1]).lineHeight).toBe(20)
expect(getNodeOrThrow(graph, created[2]).lineHeight).toBe(24) // 120% of 20
})
it('converts letterSpacing and lineHeight in style overrides', () => {
@ -487,7 +488,7 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const node = graph.getNode(created[0])!
const node = getNodeOrThrow(graph, created[0])
expect(node.styleRuns).toHaveLength(1)
expect(node.styleRuns[0].style.lineHeight).toBe(30) // 20 * 1.5
expect(node.styleRuns[0].style.letterSpacing).toBeCloseTo(-0.4) // 20 * -2/100
@ -542,7 +543,7 @@ describe('importClipboardNodes', () => {
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const component = graph.getNode(created[0])!
const component = getNodeOrThrow(graph, created[0])
expect(component.type).toBe('COMPONENT')
expect(component.layoutMode).toBe('VERTICAL')
expect(component.itemSpacing).toBe(16)
@ -598,11 +599,11 @@ describe('importClipboardNodes', () => {
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(2)
const component = graph.getNode(created[0])!
const component = getNodeOrThrow(graph, created[0])
expect(component.type).toBe('COMPONENT')
expect(graph.getChildren(component.id)).toHaveLength(1)
const instance = graph.getNode(created[1])!
const instance = getNodeOrThrow(graph, created[1])
expect(instance.type).toBe('INSTANCE')
expect(instance.componentId).toBe(component.id)
@ -662,7 +663,7 @@ describe('importClipboardNodes', () => {
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const instance = graph.getNode(created[0])!
const instance = getNodeOrThrow(graph, created[0])
expect(instance.type).toBe('INSTANCE')
expect(instance.name).toBe('Icon')
@ -717,7 +718,7 @@ describe('importClipboardNodes', () => {
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const card = graph.getNode(created[0])!
const card = getNodeOrThrow(graph, created[0])
const children = graph.getChildren(card.id)
expect(children).toHaveLength(1)
@ -792,7 +793,7 @@ describe('importClipboardNodes', () => {
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(created).toHaveLength(1)
const instance = graph.getNode(created[0])!
const instance = getNodeOrThrow(graph, created[0])
expect(instance.type).toBe('INSTANCE')
const children = graph.getChildren(instance.id)
expect(children).toHaveLength(1)
@ -845,9 +846,9 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
expect(graph.getNode(created[0])!.textAutoResize).toBe('HEIGHT')
expect(graph.getNode(created[1])!.textAutoResize).toBe('WIDTH_AND_HEIGHT')
expect(graph.getNode(created[2])!.textAutoResize).toBe('NONE')
expect(getNodeOrThrow(graph, created[0]).textAutoResize).toBe('HEIGHT')
expect(getNodeOrThrow(graph, created[1]).textAutoResize).toBe('WIDTH_AND_HEIGHT')
expect(getNodeOrThrow(graph, created[2]).textAutoResize).toBe('NONE')
})
it('undo removes all imported nodes including children', () => {
@ -940,11 +941,11 @@ describe('importClipboardNodes', () => {
const childrenBefore = graph.getChildren(pageId).length
const created = importClipboardNodes(nodeChanges, graph, pageId)
const parent = graph.getNode(created[0])!
const parent = getNodeOrThrow(graph, created[0])
const allSnapshots: SceneNode[] = []
function walk(id: string) {
const n = graph.getNode(id)!
const n = getNodeOrThrow(graph, id)
allSnapshots.push({ ...n })
for (const cid of n.childIds) walk(cid)
}
@ -962,7 +963,7 @@ describe('importClipboardNodes', () => {
})
}
const restored = graph.getNode(parent.id)!
const restored = getNodeOrThrow(graph, parent.id)
expect(restored).toBeTruthy()
expect(restored.name).toBe('Parent')
expect(restored.childIds).toHaveLength(2)
@ -1003,11 +1004,11 @@ describe('importClipboardNodes', () => {
] as NodeChange[]
const created = importClipboardNodes(nodeChanges, graph, pageId)
const parent = graph.getNode(created[0])!
const parent = getNodeOrThrow(graph, created[0])
const allSnapshots: SceneNode[] = []
function walk(id: string) {
const n = graph.getNode(id)!
const n = getNodeOrThrow(graph, id)
allSnapshots.push({ ...n })
for (const cid of n.childIds) walk(cid)
}
@ -1020,7 +1021,7 @@ describe('importClipboardNodes', () => {
graph.createNode(snapshot.type, snapshot.parentId ?? pageId, snapshot)
}
const restored = graph.getNode(parent.id)!
const restored = getNodeOrThrow(graph, parent.id)
// Bug: parent has duplicated childIds because snapshot already had [childId]
// and createNode appends childId again
expect(restored.childIds.length).toBeGreaterThan(1)

View file

@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
@ -31,21 +33,21 @@ describe('hitTest — group behavior', () => {
const { graph, page } = setup()
const hit = graph.hitTest(110, 110, page)
expect(hit).not.toBeNull()
expect(hit!.type).toBe('GROUP')
expect(expectDefined(hit, 'hit node').type).toBe('GROUP')
})
test('hitTestDeep on child returns child directly', () => {
const { graph, page, child } = setup()
const hit = graph.hitTestDeep(110, 110, page)
expect(hit).not.toBeNull()
expect(hit!.id).toBe(child.id)
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
})
test('hitTest with scope=group returns child', () => {
const { graph, group } = setup()
const hit = graph.hitTest(110, 110, group.id)
expect(hit).not.toBeNull()
expect(hit!.name).toBe('Rect')
expect(expectDefined(hit, 'hit node').name).toBe('Rect')
})
test('hitTest miss inside group returns null', () => {
@ -93,28 +95,28 @@ describe('hitTest — nested groups', () => {
const { graph, page } = setup()
const hit = graph.hitTest(100, 100, page)
expect(hit).not.toBeNull()
expect(hit!.name).toBe('GroupA')
expect(expectDefined(hit, 'hit node').name).toBe('GroupA')
})
test('click with scope=GroupA returns GroupB', () => {
const { graph, groupA } = setup()
const hit = graph.hitTest(150, 150, groupA.id)
expect(hit).not.toBeNull()
expect(hit!.name).toBe('GroupB')
expect(expectDefined(hit, 'hit node').name).toBe('GroupB')
})
test('click with scope=GroupB returns DeepRect', () => {
const { graph, groupB } = setup()
const hit = graph.hitTest(160, 160, groupB.id)
expect(hit).not.toBeNull()
expect(hit!.name).toBe('DeepRect')
expect(expectDefined(hit, 'hit node').name).toBe('DeepRect')
})
test('hitTestDeep from page returns deepest child', () => {
const { graph, page, rect } = setup()
const hit = graph.hitTestDeep(160, 160, page)
expect(hit).not.toBeNull()
expect(hit!.id).toBe(rect.id)
expect(expectDefined(hit, 'hit node').id).toBe(rect.id)
})
})
@ -140,7 +142,7 @@ describe('hitTest — locked nodes', () => {
const hit = graph.hitTestDeep(10, 10, page)
expect(hit).not.toBeNull()
expect(hit!.name).toBe('LockedFrame')
expect(expectDefined(hit, 'hit node').name).toBe('LockedFrame')
})
test('locked leaf node is still clickable', () => {
@ -157,7 +159,7 @@ describe('hitTest — locked nodes', () => {
const hit = graph.hitTest(25, 25, page)
expect(hit).not.toBeNull()
expect(hit!.name).toBe('LockedRect')
expect(expectDefined(hit, 'hit node').name).toBe('LockedRect')
})
})
@ -194,7 +196,7 @@ describe('scene graph — locked node operations', () => {
const node = graph.getNode(rect.id)
expect(node).not.toBeNull()
expect(node!.locked).toBe(true)
expect(expectDefined(node, 'node').locked).toBe(true)
})
test('lock can be toggled', () => {
@ -208,11 +210,11 @@ describe('scene graph — locked node operations', () => {
height: 50
})
expect(graph.getNode(rect.id)!.locked).toBe(false)
expect(getNodeOrThrow(graph, rect.id).locked).toBe(false)
graph.updateNode(rect.id, { locked: true })
expect(graph.getNode(rect.id)!.locked).toBe(true)
expect(getNodeOrThrow(graph, rect.id).locked).toBe(true)
graph.updateNode(rect.id, { locked: false })
expect(graph.getNode(rect.id)!.locked).toBe(false)
expect(getNodeOrThrow(graph, rect.id).locked).toBe(false)
})
test('visibility can be toggled', () => {
@ -226,11 +228,11 @@ describe('scene graph — locked node operations', () => {
height: 50
})
expect(graph.getNode(rect.id)!.visible).toBe(true)
expect(getNodeOrThrow(graph, rect.id).visible).toBe(true)
graph.updateNode(rect.id, { visible: false })
expect(graph.getNode(rect.id)!.visible).toBe(false)
expect(getNodeOrThrow(graph, rect.id).visible).toBe(false)
graph.updateNode(rect.id, { visible: true })
expect(graph.getNode(rect.id)!.visible).toBe(true)
expect(getNodeOrThrow(graph, rect.id).visible).toBe(true)
})
})
@ -255,7 +257,7 @@ describe('hitTest — frame with children', () => {
const hit = graph.hitTest(70, 70, frame.id)
expect(hit).not.toBeNull()
expect(hit!.id).toBe(child.id)
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
})
test('rotated frame scope hit test finds children using rotated local bounds', () => {
@ -280,7 +282,7 @@ describe('hitTest — frame with children', () => {
// point that is inside rotated frame bounds and child bounds
const hitInside = graph.hitTest(193, 111, frame.id)
expect(hitInside).not.toBeNull()
expect(hitInside!.id).toBe(child.id)
expect(expectDefined(hitInside, 'inside hit node').id).toBe(child.id)
// point that would be inside if frame were not rotated
const hitOutside = graph.hitTest(160, 130, frame.id)
@ -309,7 +311,7 @@ describe('hitTest — opaque containers (COMPONENT/INSTANCE)', () => {
const hit = graph.hitTest(10, 10, page)
expect(hit).not.toBeNull()
expect(hit!.name).toBe('MyComp')
expect(expectDefined(hit, 'hit node').name).toBe('MyComp')
})
test('hitTestDeep inside COMPONENT scope finds child', () => {
@ -332,7 +334,7 @@ describe('hitTest — opaque containers (COMPONENT/INSTANCE)', () => {
const hit = graph.hitTestDeep(10, 10, comp.id)
expect(hit).not.toBeNull()
expect(hit!.id).toBe(child.id)
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
})
test('hitTest on INSTANCE returns instance itself', () => {
@ -355,7 +357,7 @@ describe('hitTest — opaque containers (COMPONENT/INSTANCE)', () => {
const hit = graph.hitTest(55, 55, page)
expect(hit).not.toBeNull()
expect(hit!.name).toBe('MyInstance')
expect(expectDefined(hit, 'hit node').name).toBe('MyInstance')
})
test('hitTestDeep inside INSTANCE scope finds child', () => {
@ -378,7 +380,7 @@ describe('hitTest — opaque containers (COMPONENT/INSTANCE)', () => {
const hit = graph.hitTestDeep(55, 55, inst.id)
expect(hit).not.toBeNull()
expect(hit!.id).toBe(child.id)
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
})
})
@ -410,7 +412,7 @@ describe('hitTest — absolute position and scope offset', () => {
const hit = graph.hitTest(250, 360, frame.id)
expect(hit).not.toBeNull()
expect(hit!.id).toBe(child.id)
expect(expectDefined(hit, 'hit node').id).toBe(child.id)
const missHit = graph.hitTest(50, 60, frame.id)
expect(missHit).toBeNull()

View file

@ -8,6 +8,8 @@ import {
sceneNodeToKiwi
} from '@open-pencil/core'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
beforeAll(async () => {
await initCodec()
})
@ -55,7 +57,7 @@ describe('Fix 1: auto-layout child transforms', () => {
const blobs: Uint8Array[] = []
// Serialize the parent — children are serialized recursively
const changes = sceneNodeToKiwi(
graph.getNode(parent.id)!,
getNodeOrThrow(graph, parent.id),
ROOT_GUID,
0,
{ value: 100 },
@ -64,7 +66,10 @@ describe('Fix 1: auto-layout child transforms', () => {
) as Record<string, unknown>[]
// changes[0] = parent, changes[1] = child
const childNc = changes.find((nc) => nc.name === 'Child')!
const childNc = expectDefined(
changes.find((nc) => nc.name === 'Child'),
'child node change'
)
expect(childNc).toBeDefined()
expect(childNc.transform.m02).toBe(0)
expect(childNc.transform.m12).toBe(0)
@ -93,7 +98,7 @@ describe('Fix 1: auto-layout child transforms', () => {
const blobs: Uint8Array[] = []
const changes = sceneNodeToKiwi(
graph.getNode(parent.id)!,
getNodeOrThrow(graph, parent.id),
ROOT_GUID,
0,
{ value: 100 },
@ -101,7 +106,10 @@ describe('Fix 1: auto-layout child transforms', () => {
blobs
) as Record<string, unknown>[]
const absNc = changes.find((nc) => nc.name === 'AbsChild')!
const absNc = expectDefined(
changes.find((nc) => nc.name === 'AbsChild'),
'absolute child node change'
)
expect(absNc).toBeDefined()
expect(absNc.transform.m02).toBe(75)
expect(absNc.transform.m12).toBe(120)
@ -128,7 +136,7 @@ describe('Fix 1: auto-layout child transforms', () => {
const blobs: Uint8Array[] = []
const changes = sceneNodeToKiwi(
graph.getNode(parent.id)!,
getNodeOrThrow(graph, parent.id),
ROOT_GUID,
0,
{ value: 100 },
@ -136,7 +144,10 @@ describe('Fix 1: auto-layout child transforms', () => {
blobs
) as Record<string, unknown>[]
const childNc = changes.find((nc) => nc.name === 'Child')!
const childNc = expectDefined(
changes.find((nc) => nc.name === 'Child'),
'child node change'
)
expect(childNc).toBeDefined()
expect(childNc.transform.m02).toBe(30)
expect(childNc.transform.m12).toBe(45)
@ -164,7 +175,7 @@ describe('Fix 1: auto-layout child transforms', () => {
const blobs: Uint8Array[] = []
const changes = sceneNodeToKiwi(
graph.getNode(parent.id)!,
getNodeOrThrow(graph, parent.id),
ROOT_GUID,
0,
{ value: 100 },
@ -172,7 +183,10 @@ describe('Fix 1: auto-layout child transforms', () => {
blobs
) as Record<string, unknown>[]
const itemNc = changes.find((nc) => nc.name === 'Item')!
const itemNc = expectDefined(
changes.find((nc) => nc.name === 'Item'),
'item node change'
)
expect(itemNc.transform.m02).toBe(0)
expect(itemNc.transform.m12).toBe(0)
})
@ -241,7 +255,10 @@ describe('Fix 2: frameMaskDisabled is inverse of clipsContent', () => {
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const frame = [...reimported.nodes.values()].find((n) => n.name === 'ClipFrame')!
const frame = expectDefined(
[...reimported.nodes.values()].find((n) => n.name === 'ClipFrame'),
'ClipFrame'
)
expect(frame.clipsContent).toBe(true)
})
})
@ -382,7 +399,10 @@ describe('Fix 4: text lineHeight serialization', () => {
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const textNode = [...reimported.nodes.values()].find((n) => n.type === 'TEXT')!
const textNode = expectDefined(
[...reimported.nodes.values()].find((n) => n.type === 'TEXT'),
'text node'
)
expect(textNode.lineHeight).toBe(28)
})
})
@ -409,7 +429,10 @@ describe('Fix 5: font family normalization in derivedTextData', () => {
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const textNode = [...reimported.nodes.values()].find((n) => n.type === 'TEXT')!
const textNode = expectDefined(
[...reimported.nodes.values()].find((n) => n.type === 'TEXT'),
'text node'
)
expect(textNode.fontFamily).toBe('DM Sans')
})
@ -430,7 +453,10 @@ describe('Fix 5: font family normalization in derivedTextData', () => {
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const textNode = [...reimported.nodes.values()].find((n) => n.type === 'TEXT')!
const textNode = expectDefined(
[...reimported.nodes.values()].find((n) => n.type === 'TEXT'),
'text node'
)
expect(textNode.fontFamily).toBe('Roboto')
})
@ -451,7 +477,10 @@ describe('Fix 5: font family normalization in derivedTextData', () => {
const exported = await exportFigFile(graph)
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const textNode = [...reimported.nodes.values()].find((n) => n.type === 'TEXT')!
const textNode = expectDefined(
[...reimported.nodes.values()].find((n) => n.type === 'TEXT'),
'text node'
)
expect(textNode.fontFamily).toBe('Inter')
})
@ -533,7 +562,7 @@ describe('Integration: auto-layout component with all fixes', () => {
// Verify kiwi output directly
const blobs: Uint8Array[] = []
const changes = sceneNodeToKiwi(
graph.getNode(card.id)!,
getNodeOrThrow(graph, card.id),
ROOT_GUID,
0,
{ value: 100 },
@ -542,8 +571,14 @@ describe('Integration: auto-layout component with all fixes', () => {
) as Record<string, unknown>[]
const cardNc = changes[0]
const titleNc = changes.find((nc) => nc.name === 'Title')!
const valueNc = changes.find((nc) => nc.name === 'Value')!
const titleNc = expectDefined(
changes.find((nc) => nc.name === 'Title'),
'Title node change'
)
const valueNc = expectDefined(
changes.find((nc) => nc.name === 'Value'),
'Value node change'
)
// Fix 1: children have zero transforms
expect(titleNc.transform.m02).toBe(0)
@ -571,8 +606,14 @@ describe('Integration: auto-layout component with all fixes', () => {
const reimported = await parseFigFile(exported.buffer as ArrayBuffer)
const nodes = [...reimported.nodes.values()]
const cardNode = nodes.find((n) => n.name === 'StatCard')!
const titleNode = nodes.find((n) => n.name === 'Title')!
const cardNode = expectDefined(
nodes.find((n) => n.name === 'StatCard'),
'StatCard node'
)
const titleNode = expectDefined(
nodes.find((n) => n.name === 'Title'),
'Title node'
)
expect(cardNode.layoutMode).toBe('VERTICAL')
expect(cardNode.clipsContent).toBe(true)

View file

@ -10,6 +10,7 @@ import {
import { createEditorStore } from '@/app/editor/session'
import { getNodeOrThrow } from '#tests/helpers/assert'
import { autoFrame, loadFixtureGraph, pageId, rect } from '#tests/helpers/layout'
describe('Auto Layout', () => {
@ -394,7 +395,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.width).toBe(130)
})
@ -413,7 +414,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.height).toBe(110)
})
@ -433,7 +434,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.width).toBe(160)
expect(f.height).toBe(90)
})
@ -591,7 +592,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.width).toBe(50)
expect(f.height).toBe(30)
})
@ -616,7 +617,7 @@ describe('Auto Layout', () => {
computeLayout(graph, outer.id)
const children = graph.getChildren(outer.id)
const innerNode = graph.getNode(inner.id)!
const innerNode = getNodeOrThrow(graph, inner.id)
expect(innerNode.width).toBe(50)
expect(innerNode.height).toBe(50)
expect(children[1].x).toBe(0)
@ -663,7 +664,7 @@ describe('Auto Layout', () => {
computeLayout(graph, outer.id)
const innerNode = graph.getNode(inner.id)!
const innerNode = getNodeOrThrow(graph, inner.id)
expect(innerNode.width).toBe(105)
expect(innerNode.x).toBe(0)
@ -692,7 +693,7 @@ describe('Auto Layout', () => {
computeLayout(graph, outer.id)
const innerNode = graph.getNode(inner.id)!
const innerNode = getNodeOrThrow(graph, inner.id)
expect(innerNode.height).toBe(130)
const outerChildren = graph.getChildren(outer.id)
@ -723,7 +724,7 @@ describe('Auto Layout', () => {
computeAllLayouts(graph)
const middleNode = graph.getNode(middle.id)!
const middleNode = getNodeOrThrow(graph, middle.id)
expect(middleNode.width).toBe(105)
expect(middleNode.height).toBe(30)
@ -834,7 +835,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const c = graph.getNode(child.id)!
const c = getNodeOrThrow(graph, child.id)
expect(c.x).toBe(100)
expect(c.y).toBe(100)
})
@ -854,7 +855,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.width).toBe(60)
expect(f.height).toBe(40)
})
@ -890,7 +891,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.width).toBe(10)
expect(f.height).toBe(10)
})
@ -906,7 +907,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.width).toBe(50)
const children = graph.getChildren(frame.id)
@ -929,7 +930,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.width).toBe(400)
expect(f.height).toBe(120)
})
@ -949,7 +950,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.height).toBe(110)
expect(f.width).toBe(200)
})
@ -975,7 +976,7 @@ describe('Auto Layout', () => {
computeLayout(graph, outer.id)
const innerNode = graph.getNode(inner.id)!
const innerNode = getNodeOrThrow(graph, inner.id)
// 400 - 100 - 10 = 290
expect(innerNode.width).toBe(290)
})
@ -1155,9 +1156,9 @@ describe('Auto Layout', () => {
setTextMeasurer(null)
const updatedText = graph.getNode(text.id)!
const updatedArrow1 = graph.getNode(arrow1.id)!
const updatedArrow2 = graph.getNode(arrow2.id)!
const updatedText = getNodeOrThrow(graph, text.id)
const updatedArrow1 = getNodeOrThrow(graph, arrow1.id)
const updatedArrow2 = getNodeOrThrow(graph, arrow2.id)
expect(updatedText.width).toBe(60)
@ -1320,7 +1321,7 @@ describe('Auto Layout', () => {
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = graph.getNode(text.id)!
const updatedText = getNodeOrThrow(graph, text.id)
// Should stretch to 300 - 20 - 20 = 260, NOT stay at 100
expect(updatedText.width).toBe(260)
})
@ -1354,7 +1355,7 @@ describe('Auto Layout', () => {
computeAllLayouts(graph)
setTextMeasurer(null)
const updatedText = graph.getNode(text.id)!
const updatedText = getNodeOrThrow(graph, text.id)
expect(updatedText.width).toBe(300)
expect(updatedText.height).toBe(20)
})
@ -1395,7 +1396,7 @@ describe('Auto Layout', () => {
// 400 - 100 - 10 = 290 available for the fill text
expect(receivedWidths.length).toBeGreaterThan(0)
const updatedText = graph.getNode(text.id)!
const updatedText = getNodeOrThrow(graph, text.id)
expect(updatedText.width).toBe(290)
})
@ -1429,7 +1430,7 @@ describe('Auto Layout', () => {
setTextMeasurer(null)
expect(measureCalled).toBe(false)
const updatedText = graph.getNode(text.id)!
const updatedText = getNodeOrThrow(graph, text.id)
expect(updatedText.width).toBe(150)
expect(updatedText.height).toBe(40)
})
@ -1512,7 +1513,7 @@ describe('Auto Layout', () => {
computeLayout(graph, outer.id)
const innerNode = graph.getNode(inner.id)!
const innerNode = getNodeOrThrow(graph, inner.id)
expect(innerNode.width).toBe(250)
})
})
@ -1711,17 +1712,17 @@ describe('Auto Layout', () => {
const child = rect(graph, frame.id, 80, 60)
computeLayout(graph, frame.id)
expect(graph.getNode(child.id)!.width).toBe(80)
expect(getNodeOrThrow(graph, child.id).width).toBe(80)
graph.updateNode(child.id, { visible: false })
computeLayout(graph, frame.id)
expect(graph.getNode(child.id)!.width).toBe(80)
expect(graph.getNode(child.id)!.height).toBe(60)
expect(getNodeOrThrow(graph, child.id).width).toBe(80)
expect(getNodeOrThrow(graph, child.id).height).toBe(60)
graph.updateNode(child.id, { visible: true })
computeLayout(graph, frame.id)
expect(graph.getNode(child.id)!.width).toBe(80)
expect(graph.getNode(child.id)!.height).toBe(60)
expect(getNodeOrThrow(graph, child.id).width).toBe(80)
expect(getNodeOrThrow(graph, child.id).height).toBe(60)
})
})
@ -1740,7 +1741,7 @@ describe('Auto Layout', () => {
computeLayout(graph, frame.id)
const f = graph.getNode(frame.id)!
const f = getNodeOrThrow(graph, frame.id)
expect(f.width).toBe(100)
const children = graph.getChildren(frame.id)

View file

@ -19,6 +19,7 @@ import { SkiaRenderer } from '#core/canvas'
import { SceneGraph } from '#core/scene-graph'
import { fontManager } from '#core/text'
import { expectDefined } from '#tests/helpers/assert'
import { coreSourcePath, publicPath, testPath as repoTestPath } from '#tests/helpers/paths'
// === CLAIM EXTRACTION ===
@ -75,7 +76,7 @@ describe('Doc 01 — The Current Engine: Static Code Claims', () => {
// Use a targeted search near the Effect interface definition
const effectInterfaceMatch = src.match(/interface Effect\s*\{([^}]+)\}/)
expect(effectInterfaceMatch).toBeTruthy()
const effectBody = effectInterfaceMatch![1]
const effectBody = expectDefined(effectInterfaceMatch, 'effectInterfaceMatch')[1]
for (const type of [
'DROP_SHADOW',
'INNER_SHADOW',
@ -107,7 +108,7 @@ describe('Doc 01 — The Current Engine: Static Code Claims', () => {
/export function renderShapeUncached[\s\S]*?(?=\nexport function|\n$)/
)
expect(renderShapeUncachedMatch).toBeTruthy()
const body = renderShapeUncachedMatch![0]
const body = expectDefined(renderShapeUncachedMatch, 'renderShapeUncachedMatch')[0]
// Find indices of key calls
const behindIdx = body.indexOf("'behind'")
@ -132,7 +133,7 @@ describe('Doc 01 — The Current Engine: Static Code Claims', () => {
/export function renderNode[\s\S]*?(?=\nexport function|\nexport const)/
)
expect(renderNodeMatch).toBeTruthy()
const body = renderNodeMatch![0]
const body = expectDefined(renderNodeMatch, 'renderNodeMatch')[0]
// Verify opacity saveLayer comes before layerBlur saveLayer
const opacityLayerIdx = body.indexOf('opacity < 1')
@ -170,7 +171,7 @@ describe('Doc 01 — The Current Engine: Static Code Claims', () => {
/function drawTextInnerShadow[\s\S]*?(?=\nfunction |\nexport )/
)
expect(drawTextInnerShadowMatch).toBeTruthy()
const body = drawTextInnerShadowMatch![0]
const body = expectDefined(drawTextInnerShadowMatch, 'drawTextInnerShadowMatch')[0]
// Count saveLayer calls — there are 4 (Master, Restrictive, Blur, DstOut)
// plus 2 canvas.save() calls (child transform + offset transform)
@ -199,7 +200,7 @@ describe('Doc 01 — The Current Engine: Static Code Claims', () => {
/export function renderShape[\s\S]*?(?=\nexport function|\nexport const)/
)
expect(renderShapeMatch).toBeTruthy()
const body = renderShapeMatch![0]
const body = expectDefined(renderShapeMatch, 'renderShapeMatch')[0]
expect(body).toContain('nodePictureCache')
expect(body).toContain('PictureRecorder')
})
@ -213,7 +214,7 @@ describe('Doc 01 — The Current Engine: Static Code Claims', () => {
/function getShadowShapeChild[\s\S]*?(?=\nfunction |\nexport )/
)
expect(getShadowShapeChildFn).toBeTruthy()
const body = getShadowShapeChildFn![0]
const body = expectDefined(getShadowShapeChildFn, 'getShadowShapeChildFn')[0]
// No for loop over children
expect(body).not.toMatch(/for\s*\(.*childIds/)
// Accesses childIds twice: once for .length guard, once for [0] read
@ -243,7 +244,7 @@ describe('Doc 02 — Formula Deconstruction: Static Code Claims', () => {
/function drawTextInnerShadow[\s\S]*?(?=\nfunction |\nexport )/
)
expect(drawTextInnerShadowMatch).toBeTruthy()
const body = drawTextInnerShadowMatch![0]
const body = expectDefined(drawTextInnerShadowMatch, 'drawTextInnerShadowMatch')[0]
// 4 saveLayer: Master, Restrictive, Blur, DstOut
// 2 save: child transform, offset transform
@ -303,7 +304,7 @@ describe('Doc 03 — Artifact Analysis: Static + Runtime Verification', () => {
/function drawTextInnerShadow[\s\S]*?(?=\nfunction |\nexport )/
)
expect(drawTextInnerShadowMatch).toBeTruthy()
const body = drawTextInnerShadowMatch![0]
const body = expectDefined(drawTextInnerShadowMatch, 'drawTextInnerShadowMatch')[0]
// Check the expand calculation
expect(body).toContain('const expand')
@ -316,7 +317,7 @@ describe('Doc 03 — Artifact Analysis: Static + Runtime Verification', () => {
const src = readSource(scenePath)
const match = src.match(/function getShadowShapeChild[\s\S]*?(?=\nfunction |\nexport )/)
expect(match).toBeTruthy()
const body = match![0]
const body = expectDefined(match, 'match')[0]
// Verify it returns early after first child
expect(body).toContain('return child')
// Verify no array accumulation
@ -354,7 +355,7 @@ describe('Doc 01/03 — Runtime Behavior Verification', () => {
void fontPath
}
const surface = ck.MakeSurface(200, 200)!
const surface = expectDefined(ck.MakeSurface(200, 200), 'CanvasKit surface')
renderer = new SkiaRenderer(ck, surface)
renderer.fontProvider = fontProvider
renderer.fontsLoaded = true
@ -516,7 +517,7 @@ describe('Doc 01/03 — Runtime Behavior Verification', () => {
/function drawTextInnerShadow[\s\S]*?(?=\nfunction |\nexport )/
)
expect(drawTextInnerShadowMatch).toBeTruthy()
const body = drawTextInnerShadowMatch![0]
const body = expectDefined(drawTextInnerShadowMatch, 'drawTextInnerShadowMatch')[0]
// MakeBlend IS called (for tintFilter and solidBlackFilter)
expect(body).toContain('MakeBlend')
@ -543,7 +544,7 @@ describe('Doc 01 — LAYER_BLUR Round-Trip Loss Verification', () => {
const src = readSource(schemaPath)
const effectTypeBlock = src.match(/enum EffectType\s*\{([^}]*)\}/)
expect(effectTypeBlock).toBeTruthy()
const body = effectTypeBlock![1]
const body = expectDefined(effectTypeBlock, 'effectTypeBlock')[1]
// Count the values (each is "NAME = N;")
const valueCount = (body.match(/=\s*\d+\s*;/g) || []).length
expect(valueCount).toBe(4)
@ -576,7 +577,7 @@ describe('Doc 02/03 — Cache Infrastructure Verification', () => {
const rendererSrc = readSource(rendererPath)
const destroyMatch = rendererSrc.match(/destroy\(\)[\s\S]*?(?=\n \w|\n\})/)
expect(destroyMatch).toBeTruthy()
const body = destroyMatch![0]
const body = expectDefined(destroyMatch, 'destroyMatch')[0]
// destroy() delegates to destroyRenderer — verify the lifecycle module cleans caches
expect(body).toContain('destroyRenderer')
const lifecycleSrc = readSource(lifecyclePath)

View file

@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test'
import { SceneGraph } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
function pageId(graph: SceneGraph) {
return graph.getPages()[0].id
}
@ -16,7 +18,7 @@ describe('SceneGraph', () => {
const id = rect(graph, 'Rect', 100, 100, 200, 150)
const node = graph.getNode(id)
expect(node).toBeDefined()
expect(node!.type).toBe('RECTANGLE')
expect(expectDefined(node, 'node').type).toBe('RECTANGLE')
expect(node.x).toBe(100)
expect(node.width).toBe(200)
})
@ -146,13 +148,13 @@ describe('SceneGraph', () => {
const r = rect(graph, 'R', 100, 200)
const node = graph.getNode(r)
expect(node).toBeDefined()
expect(node!.parentId).toBe(page)
expect(expectDefined(node, 'node').parentId).toBe(page)
graph.reparentNode(r, page)
// Position should not change
const after = graph.getNode(r)
expect(after).toBeDefined()
expect(after!.x).toBe(100)
expect(after!.y).toBe(200)
expect(expectDefined(after, 'updated node').x).toBe(100)
expect(expectDefined(after, 'updated node').y).toBe(200)
expect(after.parentId).toBe(page)
expect(graph.getChildren(page)).toHaveLength(1)
})
@ -176,7 +178,7 @@ describe('SceneGraph', () => {
// Now node is inside frame at (200,100), so local coords should be (100, 50)
const after = graph.getNode(r)
expect(after).toBeDefined()
expect(after!.x).toBe(100)
expect(expectDefined(after, 'updated node').x).toBe(100)
expect(after.y).toBe(50)
// Absolute position preserved
const absAfter = graph.getAbsolutePosition(r)
@ -245,7 +247,7 @@ describe('SceneGraph', () => {
// Inner was at (100,100), frame is at (50,50), so inner's local = (50,50)
const innerNode = graph.getNode(inner)
expect(innerNode).toBeDefined()
expect(innerNode!.x).toBe(50)
expect(expectDefined(innerNode, 'inner node').x).toBe(50)
expect(innerNode.y).toBe(50)
// Child still at (10,10) relative to inner, absolute = (110,110)
expect(graph.getAbsolutePosition(child)).toEqual({ x: 110, y: 110 })
@ -326,7 +328,7 @@ describe('SceneGraph', () => {
graph.updateNode(id, { x: 200, name: 'Updated' })
const node = graph.getNode(id)
expect(node).toBeDefined()
expect(node!.x).toBe(200)
expect(expectDefined(node, 'node').x).toBe(200)
expect(node.name).toBe('Updated')
})
@ -340,7 +342,7 @@ describe('SceneGraph', () => {
const child = graph.createNode('RECTANGLE', comp.id, { name: 'BG', width: 100, height: 40 })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
expect(instance!.type).toBe('INSTANCE')
expect(expectDefined(instance, 'instance').type).toBe('INSTANCE')
expect(instance.componentId).toBe(comp.id)
const instChildren = graph.getChildren(instance.id)
expect(instChildren).toHaveLength(1)
@ -358,7 +360,7 @@ describe('SceneGraph', () => {
const label = graph.createNode('TEXT', comp.id, { name: 'Title', text: 'Hello', fontSize: 14 })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
const instLabel = graph.getChildren(instance!.id)[0]
const instLabel = graph.getChildren(expectDefined(instance, 'instance').id)[0]
expect(instLabel.text).toBe('Hello')
graph.updateNode(label.id, { text: 'Updated', fontSize: 18 })
@ -378,7 +380,7 @@ describe('SceneGraph', () => {
graph.createNode('TEXT', comp.id, { name: 'Title', text: 'Default', fontSize: 14 })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
const instLabel = graph.getChildren(instance!.id)[0]
const instLabel = graph.getChildren(expectDefined(instance, 'instance').id)[0]
// Override the text on the instance child
graph.updateNode(instLabel.id, { text: 'Custom' })
@ -403,12 +405,12 @@ describe('SceneGraph', () => {
graph.createNode('RECTANGLE', comp.id, { name: 'BG' })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
expect(graph.getChildren(instance!.id)).toHaveLength(1)
expect(graph.getChildren(expectDefined(instance, 'instance').id)).toHaveLength(1)
graph.createNode('TEXT', comp.id, { name: 'Label', text: 'New' })
graph.syncInstances(comp.id)
const instChildren = graph.getChildren(instance!.id)
const instChildren = graph.getChildren(expectDefined(instance, 'instance').id)
expect(instChildren).toHaveLength(2)
expect(instChildren[1].name).toBe('Label')
expect(instChildren[1].text).toBe('New')
@ -424,10 +426,10 @@ describe('SceneGraph', () => {
graph.createNode('RECTANGLE', comp.id, { name: 'BG' })
const instance = graph.createInstance(comp.id, pageId(graph))
expect(instance).toBeDefined()
expect(instance!.type).toBe('INSTANCE')
expect(expectDefined(instance, 'instance').type).toBe('INSTANCE')
graph.detachInstance(instance!.id)
expect(instance!.type).toBe('FRAME')
graph.detachInstance(expectDefined(instance, 'instance').id)
expect(expectDefined(instance, 'instance').type).toBe('FRAME')
expect(instance.componentId).toBeNull()
expect(graph.getInstances(comp.id)).toHaveLength(0)
})
@ -1019,13 +1021,13 @@ describe('updateNode', () => {
expect(textNode).toBeDefined()
// Simulate a cached textPicture
const fakePicture = new Uint8Array([1, 2, 3])
textNode!.textPicture = fakePicture
expectDefined(textNode, 'text node').textPicture = fakePicture
// Changing fontSize (a TEXT_PICTURE_KEY) should null the cache
graph.updateNode(textId, { fontSize: 24 })
const afterUpdate = graph.getNode(textId)
expect(afterUpdate).toBeDefined()
expect(afterUpdate!.textPicture).toBeNull()
expect(expectDefined(afterUpdate, 'updated node').textPicture).toBeNull()
})
test('textPicture survives non-text property change on TEXT node', () => {
@ -1041,13 +1043,13 @@ describe('updateNode', () => {
const fakePicture = new Uint8Array([4, 5, 6])
const textNode = graph.getNode(textId)
expect(textNode).toBeDefined()
textNode!.textPicture = fakePicture
expectDefined(textNode, 'text node').textPicture = fakePicture
// Changing opacity (NOT a TEXT_PICTURE_KEY) should preserve textPicture
graph.updateNode(textId, { opacity: 0.5 })
const afterUpdate = graph.getNode(textId)
expect(afterUpdate).toBeDefined()
expect(afterUpdate!.textPicture).toBe(fakePicture)
expect(expectDefined(afterUpdate, 'updated node').textPicture).toBe(fakePicture)
})
test('textPicture is nulled when textPicture is already null', () => {
@ -1064,7 +1066,7 @@ describe('updateNode', () => {
graph.updateNode(textId, { fontSize: 16 })
const afterUpdate = graph.getNode(textId)
expect(afterUpdate).toBeDefined()
expect(afterUpdate!.textPicture).toBeNull()
expect(expectDefined(afterUpdate, 'updated node').textPicture).toBeNull()
})
})

View file

@ -9,6 +9,8 @@ import {
vectorNetworkToSVGPaths
} from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
function makeGraph() {
const graph = new SceneGraph()
graph.createNode('CANVAS', graph.rootId, { name: 'Page 1' })
@ -23,6 +25,19 @@ function exportSVG(graph: SceneGraph, nodeIds: string[], xmlDecl = false): strin
return renderNodesToSVG(graph, pageId(graph), nodeIds, { xmlDeclaration: xmlDecl })
}
function exportSVGOrThrow(graph: SceneGraph, nodeIds: string[], xmlDecl = false): string {
return expectDefined(exportSVG(graph, nodeIds, xmlDecl), 'SVG output')
}
function renderNodesToSVGOrThrow(
graph: SceneGraph,
pageId: string,
nodeIds: string[],
options: Parameters<typeof renderNodesToSVG>[3]
): string {
return expectDefined(renderNodesToSVG(graph, pageId, nodeIds, options), 'SVG output')
}
// --- SVGNode builder tests ---
describe('svg() and renderSVGNode()', () => {
@ -210,7 +225,7 @@ describe('renderNodesToSVG()', () => {
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<svg')
expect(result).toContain('width="100"')
expect(result).toContain('height="50"')
@ -226,10 +241,12 @@ describe('renderNodesToSVG()', () => {
height: 10,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const withDecl = renderNodesToSVG(graph, pageId(graph), [node.id], { xmlDeclaration: true })!
const withoutDecl = renderNodesToSVG(graph, pageId(graph), [node.id], {
const withDecl = renderNodesToSVGOrThrow(graph, pageId(graph), [node.id], {
xmlDeclaration: true
})
const withoutDecl = renderNodesToSVGOrThrow(graph, pageId(graph), [node.id], {
xmlDeclaration: false
})!
})
expect(withDecl).toStartWith('<?xml')
expect(withoutDecl).toStartWith('<svg')
})
@ -241,7 +258,7 @@ describe('renderNodesToSVG()', () => {
height: 60,
fills: [{ type: 'SOLID', color: { r: 0, g: 0.5, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<ellipse')
expect(result).toContain('cx="40"')
expect(result).toContain('cy="30"')
@ -264,7 +281,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<line')
expect(result).toContain('x2="100"')
expect(result).toContain('stroke-width="2"')
@ -279,7 +296,7 @@ describe('renderNodesToSVG()', () => {
starInnerRadius: 0.382,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<polygon')
expect(result).toContain('points="')
})
@ -292,7 +309,7 @@ describe('renderNodesToSVG()', () => {
pointCount: 6,
fills: [{ type: 'SOLID', color: { r: 0, g: 1, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<polygon')
})
@ -304,7 +321,7 @@ describe('renderNodesToSVG()', () => {
cornerRadius: 16,
fills: [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('rx="16"')
expect(result).toContain('ry="16"')
})
@ -322,7 +339,7 @@ describe('renderNodesToSVG()', () => {
bottomLeftRadius: 5,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<path')
expect(result).toContain('d="M10 0')
})
@ -338,7 +355,7 @@ describe('renderNodesToSVG()', () => {
fontFamily: 'Inter',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<text')
expect(result).toContain('font-size="18"')
expect(result).toContain('font-weight="700"')
@ -357,7 +374,7 @@ describe('renderNodesToSVG()', () => {
textAlignHorizontal: 'LEFT',
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('direction="rtl"')
expect(result).toContain('text-anchor="end"')
expect(result).toContain('x="180"')
@ -377,7 +394,7 @@ describe('renderNodesToSVG()', () => {
{ start: 6, length: 4, style: { fontWeight: 700 } }
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<tspan')
expect(result).toContain('font-weight="700"')
})
@ -390,7 +407,7 @@ describe('renderNodesToSVG()', () => {
opacity: 0.5,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('opacity="0.5"')
})
@ -402,7 +419,7 @@ describe('renderNodesToSVG()', () => {
rotation: 45,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('rotate(45,')
})
@ -422,7 +439,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('stroke-dasharray="5 3"')
})
@ -443,7 +460,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('stroke-linecap="round"')
expect(result).toContain('stroke-linejoin="bevel"')
})
@ -463,7 +480,7 @@ describe('renderNodesToSVG()', () => {
y: 10,
fills: [{ type: 'SOLID', color: { r: 0.9, g: 0.9, b: 0.9, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [frame.id])!
const result = exportSVGOrThrow(graph, [frame.id])
expect(result).toContain('<g')
expect(result).toContain('translate(10, 10)')
})
@ -481,7 +498,7 @@ describe('renderNodesToSVG()', () => {
height: 200,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [frame.id])!
const result = exportSVGOrThrow(graph, [frame.id])
expect(result).toContain('<clipPath')
expect(result).toContain('clip-path="url(#clip')
})
@ -505,7 +522,7 @@ describe('renderNodesToSVG()', () => {
visible: false,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [frame.id])!
const result = exportSVGOrThrow(graph, [frame.id])
expect(result).toContain('fill="#FF0000"')
expect(result).not.toContain('fill="#0000FF"')
})
@ -529,7 +546,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<linearGradient')
expect(result).toContain('<stop')
expect(result).toContain('url(#grad')
@ -554,7 +571,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<radialGradient')
expect(result).toContain('url(#grad')
})
@ -576,7 +593,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<filter')
expect(result).toContain('<feDropShadow')
expect(result).toContain('dy="4"')
@ -600,7 +617,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<feGaussianBlur')
expect(result).toContain('stdDeviation="5"')
})
@ -621,7 +638,7 @@ describe('renderNodesToSVG()', () => {
height: 40,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [a.id, b.id])!
const result = exportSVGOrThrow(graph, [a.id, b.id])
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('<rect')
expect(result).toContain('<ellipse')
@ -635,7 +652,7 @@ describe('renderNodesToSVG()', () => {
blendMode: 'MULTIPLY',
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('mix-blend-mode: multiply')
})
@ -646,7 +663,7 @@ describe('renderNodesToSVG()', () => {
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 0.5, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('fill="#FF0000')
})
@ -664,7 +681,7 @@ describe('renderNodesToSVG()', () => {
y: 10,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [comp.id])!
const result = exportSVGOrThrow(graph, [comp.id])
expect(result).toContain('<rect')
expect(result).toContain('translate(10, 10)')
})
@ -678,7 +695,7 @@ describe('renderNodesToSVG()', () => {
{ type: 'SOLID', color: { r: 0.95, g: 0.95, b: 0.95, a: 1 }, opacity: 1, visible: true }
]
})
const result = exportSVG(graph, [section.id])!
const result = exportSVGOrThrow(graph, [section.id])
expect(result).toContain('<rect')
expect(result).toContain('width="500"')
})
@ -691,7 +708,7 @@ describe('renderNodesToSVG()', () => {
flipX: true,
fills: [{ type: 'SOLID', color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('scale(-1, 1)')
})
@ -710,7 +727,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('stroke-opacity="0.5"')
})
@ -735,7 +752,7 @@ describe('renderNodesToSVG()', () => {
height: 50,
fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0, a: 1 }, opacity: 1, visible: true }]
})
const result = exportSVG(graph, [group.id])!
const result = exportSVGOrThrow(graph, [group.id])
expect(result).toContain('<rect')
expect(result).toContain('fill="#FF0000"')
})
@ -757,7 +774,7 @@ describe('renderNodesToSVG()', () => {
}
]
})
const result = exportSVG(graph, [node.id])!
const result = exportSVGOrThrow(graph, [node.id])
expect(result).toContain('<filter')
expect(result).toContain('<feGaussianBlur')
expect(result).toContain('<feComposite')

View file

@ -4,6 +4,8 @@ import type { CanvasKit } from 'canvaskit-wasm'
import { TextEditor, type SceneNode } from '@open-pencil/core'
import { expectDefined } from '#tests/helpers/assert'
const mockCk = {} as CanvasKit
function createEditor(text = 'Hello World') {
@ -13,6 +15,10 @@ function createEditor(text = 'Hello World') {
return { editor, node }
}
function editorState(editor: TextEditor) {
return expectDefined(editor.state, 'text editor state')
}
describe('TextEditor', () => {
test('start initializes state', () => {
const { editor } = createEditor()
@ -45,15 +51,15 @@ describe('TextEditor', () => {
test('insert at cursor position', () => {
const { editor, node } = createEditor()
editor.state!.cursor = 5
editorState(editor).cursor = 5
editor.insert(' Beautiful', node)
expect(editor.state?.text).toBe('Hello Beautiful World')
})
test('insert replaces selection', () => {
const { editor, node } = createEditor()
editor.state!.selectionAnchor = 0
editor.state!.cursor = 5
editorState(editor).selectionAnchor = 0
editorState(editor).cursor = 5
editor.insert('Goodbye', node)
expect(editor.state?.text).toBe('Goodbye World')
expect(editor.state?.cursor).toBe(7)
@ -69,15 +75,15 @@ describe('TextEditor', () => {
test('backspace at start does nothing', () => {
const { editor, node } = createEditor()
editor.state!.cursor = 0
editorState(editor).cursor = 0
editor.backspace(node)
expect(editor.state?.text).toBe('Hello World')
})
test('backspace deletes selection', () => {
const { editor, node } = createEditor()
editor.state!.selectionAnchor = 6
editor.state!.cursor = 11
editorState(editor).selectionAnchor = 6
editorState(editor).cursor = 11
editor.backspace(node)
expect(editor.state?.text).toBe('Hello ')
expect(editor.state?.cursor).toBe(6)
@ -85,7 +91,7 @@ describe('TextEditor', () => {
test('delete removes char after cursor', () => {
const { editor, node } = createEditor()
editor.state!.cursor = 0
editorState(editor).cursor = 0
editor.delete(node)
expect(editor.state?.text).toBe('ello World')
expect(editor.state?.cursor).toBe(0)
@ -99,8 +105,8 @@ describe('TextEditor', () => {
test('delete removes selection', () => {
const { editor, node } = createEditor()
editor.state!.selectionAnchor = 0
editor.state!.cursor = 5
editorState(editor).selectionAnchor = 0
editorState(editor).cursor = 5
editor.delete(node)
expect(editor.state?.text).toBe(' World')
})
@ -119,8 +125,8 @@ describe('TextEditor', () => {
test('moveLeft collapses selection to start', () => {
const { editor } = createEditor()
editor.state!.selectionAnchor = 3
editor.state!.cursor = 8
editorState(editor).selectionAnchor = 3
editorState(editor).cursor = 8
editor.moveLeft()
expect(editor.state?.cursor).toBe(3)
expect(editor.state?.selectionAnchor).toBeNull()
@ -128,8 +134,8 @@ describe('TextEditor', () => {
test('moveRight collapses selection to end', () => {
const { editor } = createEditor()
editor.state!.selectionAnchor = 3
editor.state!.cursor = 8
editorState(editor).selectionAnchor = 3
editorState(editor).cursor = 8
editor.moveRight()
expect(editor.state?.cursor).toBe(8)
expect(editor.state?.selectionAnchor).toBeNull()
@ -145,28 +151,28 @@ describe('TextEditor', () => {
test('hasSelection', () => {
const { editor } = createEditor()
expect(editor.hasSelection()).toBe(false)
editor.state!.selectionAnchor = 0
editorState(editor).selectionAnchor = 0
expect(editor.hasSelection()).toBe(true)
})
test('hasSelection false when anchor equals cursor', () => {
const { editor } = createEditor()
editor.state!.selectionAnchor = editor.state!.cursor
editorState(editor).selectionAnchor = editorState(editor).cursor
expect(editor.hasSelection()).toBe(false)
})
test('getSelectionRange', () => {
const { editor } = createEditor()
expect(editor.getSelectionRange()).toBeNull()
editor.state!.selectionAnchor = 8
editor.state!.cursor = 3
editorState(editor).selectionAnchor = 8
editorState(editor).cursor = 3
expect(editor.getSelectionRange()).toEqual([3, 8])
})
test('getSelectedText', () => {
const { editor } = createEditor()
editor.state!.selectionAnchor = 0
editor.state!.cursor = 5
editorState(editor).selectionAnchor = 0
editorState(editor).cursor = 5
expect(editor.getSelectedText()).toBe('Hello')
})
@ -192,14 +198,14 @@ describe('TextEditor', () => {
test('moveWordRight from start', () => {
const { editor } = createEditor()
editor.state!.cursor = 0
editorState(editor).cursor = 0
editor.moveWordRight()
expect(editor.state?.cursor).toBe(6)
})
test('moveLeft with extend creates selection', () => {
const { editor } = createEditor()
editor.state!.cursor = 5
editorState(editor).cursor = 5
editor.moveLeft(true)
expect(editor.state?.selectionAnchor).toBe(5)
expect(editor.state?.cursor).toBe(4)