chore(tests): guard component and cache assertions

- Remove non-null assertions from component, visibility, scene cache, and redo sequence tests
- Add explicit node, store, renderer, and canvas bounds guards
- Validate check plus affected engine and E2E coverage
This commit is contained in:
Danila Poyarkov 2026-05-06 11:07:47 +03:00
parent f7b20a8f38
commit 86cb3970ff
4 changed files with 90 additions and 57 deletions

View file

@ -1,5 +1,6 @@
import { expect, test, type Page } from '@playwright/test' import { expect, test, type Page } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas' import { CanvasHelper } from '#tests/helpers/canvas'
let page: Page let page: Page
@ -61,9 +62,10 @@ test('create component from selection (⌘⌥K)', async () => {
const ids = await getSelectedIds() const ids = await getSelectedIds()
expect(ids).toHaveLength(1) expect(ids).toHaveLength(1)
const node = await getNodeById(ids[0]) const selectedId = expectDefined(ids[0], 'selected component id')
expect(node!.type).toBe('COMPONENT') const node = await getNodeById(selectedId)
componentId = ids[0] expect(node?.type).toBe('COMPONENT')
componentId = selectedId
}) })
test('component shows purple label in design panel', async () => { test('component shows purple label in design panel', async () => {
@ -94,21 +96,26 @@ test('create instance from component (context menu)', async () => {
const store = window.openPencil?.store const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized') if (!store) throw new Error('OpenPencil store not initialized')
store.createInstanceFromComponent(compId, 300, 100) store.createInstanceFromComponent(compId, 300, 100)
}, comp!.id) }, expectDefined(comp, 'component').id)
await canvas.waitForRender() await canvas.waitForRender()
const updated = await getPageChildren() const updated = await getPageChildren()
const instance = updated.find((c) => c.type === 'INSTANCE') const instance = updated.find((c) => c.type === 'INSTANCE')
expect(instance).toBeTruthy() expect(instance).toBeTruthy()
expect(instance!.componentId).toBe(comp!.id) expect(instance?.componentId).toBe(expectDefined(comp, 'component').id)
}) })
test('instance shows INSTANCE type in design panel', async () => { test('instance shows INSTANCE type in design panel', async () => {
const children = await getPageChildren() const children = await getPageChildren()
const instance = children.find((c) => c.type === 'INSTANCE')! const instance = expectDefined(
children.find((c) => c.type === 'INSTANCE'),
'instance node'
)
await page.evaluate((id) => { await page.evaluate((id) => {
window.openPencil?.store!.select([id]) const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, instance.id) }, instance.id)
await canvas.waitForRender() await canvas.waitForRender()
@ -129,7 +136,9 @@ test('instance has "Detach" button', async () => {
test('modifying component propagates to instance', async () => { test('modifying component propagates to instance', async () => {
// Select the component // Select the component
await page.evaluate((id) => { await page.evaluate((id) => {
window.openPencil?.store!.select([id]) const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, componentId) }, componentId)
await canvas.waitForRender() await canvas.waitForRender()
@ -157,7 +166,10 @@ test('modifying component propagates to instance', async () => {
// Check instance got the same fill // Check instance got the same fill
const children = await getPageChildren() const children = await getPageChildren()
const instance = children.find((c) => c.type === 'INSTANCE')! const instance = expectDefined(
children.find((c) => c.type === 'INSTANCE'),
'instance node'
)
const instanceNode = await page.evaluate((id) => { const instanceNode = await page.evaluate((id) => {
const store = window.openPencil?.store const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized') if (!store) throw new Error('OpenPencil store not initialized')
@ -171,21 +183,28 @@ test('modifying component propagates to instance', async () => {
test('detach instance converts to frame', async () => { test('detach instance converts to frame', async () => {
const children = await getPageChildren() const children = await getPageChildren()
const instance = children.find((c) => c.type === 'INSTANCE')! const instance = expectDefined(
children.find((c) => c.type === 'INSTANCE'),
'instance node'
)
await page.evaluate((id) => { await page.evaluate((id) => {
window.openPencil?.store!.select([id]) const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.select([id])
}, instance.id) }, instance.id)
await canvas.waitForRender() await canvas.waitForRender()
await page.evaluate(() => { await page.evaluate(() => {
window.openPencil?.store!.detachInstance() const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.detachInstance()
}) })
await canvas.waitForRender() await canvas.waitForRender()
const ids = await getSelectedIds() const ids = await getSelectedIds()
const detached = await getNodeById(ids[0]) const detached = await getNodeById(expectDefined(ids[0], 'detached selected id'))
expect(detached!.type).toBe('FRAME') expect(detached?.type).toBe('FRAME')
canvas.assertNoErrors() canvas.assertNoErrors()
}) })

View file

@ -1,5 +1,6 @@
import { expect, test, type Page } from '@playwright/test' import { expect, test, type Page } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas' import { CanvasHelper } from '#tests/helpers/canvas'
let page: Page let page: Page
@ -40,21 +41,21 @@ test('fill visibility supports repeat click and undo redo', async () => {
const fillButton = page.locator('[data-test-id="fill-visibility-0"]') const fillButton = page.locator('[data-test-id="fill-visibility-0"]')
await expect(fillButton).toBeVisible() await expect(fillButton).toBeVisible()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(true)
await fillButton.click() await fillButton.click()
await canvas.waitForRender() await canvas.waitForRender()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(false) expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(false)
await fillButton.click() await fillButton.click()
await canvas.waitForRender() await canvas.waitForRender()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(true)
await canvas.undo() await canvas.undo()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(false) expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(false)
await canvas.redo() await canvas.redo()
expect((await getSelectedNode())!.fills[0]?.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').fills[0]?.visible).toBe(true)
}) })
test('stroke visibility supports repeat click and undo redo', async () => { test('stroke visibility supports repeat click and undo redo', async () => {
@ -63,39 +64,39 @@ test('stroke visibility supports repeat click and undo redo', async () => {
const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]') const strokeButton = page.locator('[data-test-id="stroke-visibility-0"]')
await expect(strokeButton).toBeVisible() await expect(strokeButton).toBeVisible()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(true)
await strokeButton.click() await strokeButton.click()
await canvas.waitForRender() await canvas.waitForRender()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false) expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(false)
await strokeButton.click() await strokeButton.click()
await canvas.waitForRender() await canvas.waitForRender()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(true)
await canvas.undo() await canvas.undo()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(false) expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(false)
await canvas.redo() await canvas.redo()
expect((await getSelectedNode())!.strokes[0]?.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').strokes[0]?.visible).toBe(true)
}) })
test('appearance visibility supports repeat click and undo redo in one step', async () => { test('appearance visibility supports repeat click and undo redo in one step', async () => {
const visibilityButton = page.locator('[data-test-id="appearance-visibility"]') const visibilityButton = page.locator('[data-test-id="appearance-visibility"]')
await expect(visibilityButton).toBeVisible() await expect(visibilityButton).toBeVisible()
expect((await getSelectedNode())!.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(true)
await visibilityButton.click() await visibilityButton.click()
await canvas.waitForRender() await canvas.waitForRender()
expect((await getSelectedNode())!.visible).toBe(false) expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(false)
await visibilityButton.click() await visibilityButton.click()
await canvas.waitForRender() await canvas.waitForRender()
expect((await getSelectedNode())!.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(true)
await canvas.undo() await canvas.undo()
expect((await getSelectedNode())!.visible).toBe(false) expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(false)
await canvas.undo() await canvas.undo()
expect((await getSelectedNode())!.visible).toBe(true) expect(expectDefined(await getSelectedNode(), 'selected node').visible).toBe(true)
}) })

View file

@ -1,5 +1,6 @@
import { test, expect } from '@playwright/test' import { test, expect } from '@playwright/test'
import { expectDefined } from '#tests/helpers/assert'
import { CanvasHelper } from '#tests/helpers/canvas' import { CanvasHelper } from '#tests/helpers/canvas'
test.describe('SkPicture scene caching', () => { test.describe('SkPicture scene caching', () => {
@ -78,7 +79,9 @@ test.describe('SkPicture scene caching', () => {
const store = window.openPencil?.store const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized') if (!store) throw new Error('OpenPencil store not initialized')
// Simulate what loadFonts() does: invalidate the cached picture // Simulate what loadFonts() does: invalidate the cached picture
store.renderer!.invalidateScenePicture() const renderer = store.renderer
if (!renderer) throw new Error('OpenPencil renderer not initialized')
renderer.invalidateScenePicture()
store.requestRender() store.requestRender()
}) })
await helper.waitForRender() await helper.waitForRender()
@ -98,7 +101,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => { await helper.page.evaluate(() => {
const store = window.openPencil?.store const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized') if (!store) throw new Error('OpenPencil store not initialized')
const pg = store.graph.getNode(store.state.currentPageId)! const pg = store.graph.getNode(store.state.currentPageId)
if (!pg) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = pg.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME') const frame = pg.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
store.setHoveredNode(frame ?? null) store.setHoveredNode(frame ?? null)
}) })
@ -137,7 +141,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => { await helper.page.evaluate(() => {
const store = window.openPencil?.store const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized') if (!store) throw new Error('OpenPencil store not initialized')
const page = store.graph.getNode(store.state.currentPageId)! const page = store.graph.getNode(store.state.currentPageId)
if (!page) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME') const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
store.setHoveredNode(frame ?? null) store.setHoveredNode(frame ?? null)
}) })
@ -162,7 +167,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => { await helper.page.evaluate(() => {
const store = window.openPencil?.store const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized') if (!store) throw new Error('OpenPencil store not initialized')
const page = store.graph.getNode(store.state.currentPageId)! const page = store.graph.getNode(store.state.currentPageId)
if (!page) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME') const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@ -186,15 +192,19 @@ test.describe('SkPicture scene caching', () => {
await helper.waitForRender() await helper.waitForRender()
const baseline = await helper.screenshotCanvas() const baseline = await helper.screenshotCanvas()
const box = await helper.canvas.boundingBox() const box = expectDefined(await helper.canvas.boundingBox(), 'canvas bounds')
// Move mouse over the frame (at 200, 150 — center of the 300x200 frame at 50,50) // Move mouse over the frame (at 200, 150 — center of the 300x200 frame at 50,50)
await helper.page.mouse.move(box!.x + 200, box!.y + 150) await helper.page.mouse.move(box.x + 200, box.y + 150)
await helper.waitForRender() await helper.waitForRender()
await helper.page.waitForTimeout(100) await helper.page.waitForTimeout(100)
// Move mouse to empty area (far from any node) // Move mouse to empty area (far from any node)
await helper.page.mouse.move(box!.x + 800, box!.y + 600) await helper.page.mouse.move(box.x + 800, box.y + 600)
await helper.page.evaluate(() => window.openPencil?.store!.setHoveredNode(null)) await helper.page.evaluate(() => {
const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized')
store.setHoveredNode(null)
})
await helper.waitForRender() await helper.waitForRender()
await helper.page.waitForTimeout(100) await helper.page.waitForTimeout(100)
@ -208,7 +218,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => { await helper.page.evaluate(() => {
const store = window.openPencil?.store const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized') if (!store) throw new Error('OpenPencil store not initialized')
const page = store.graph.getNode(store.state.currentPageId)! const page = store.graph.getNode(store.state.currentPageId)
if (!page) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME') const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
if (frame) store.graph.updateNode(frame, { width: 310 }) if (frame) store.graph.updateNode(frame, { width: 310 })
store.requestRender() store.requestRender()
@ -219,7 +230,8 @@ test.describe('SkPicture scene caching', () => {
await helper.page.evaluate(() => { await helper.page.evaluate(() => {
const store = window.openPencil?.store const store = window.openPencil?.store
if (!store) throw new Error('OpenPencil store not initialized') if (!store) throw new Error('OpenPencil store not initialized')
const page = store.graph.getNode(store.state.currentPageId)! const page = store.graph.getNode(store.state.currentPageId)
if (!page) throw new Error(`Page ${store.state.currentPageId} not found`)
const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME') const frame = page.childIds.find((id: string) => store.graph.getNode(id)?.type === 'FRAME')
store.setHoveredNode(frame ?? null) store.setHoveredNode(frame ?? null)
}) })

View file

@ -1,5 +1,6 @@
import { describe, test, expect } from 'bun:test' import { describe, test, expect } from 'bun:test'
import { expectDefined, getNodeOrThrow } from '#tests/helpers/assert'
import { createHistoryFrame, setupEditorPage } from '#tests/helpers/editor-history' import { createHistoryFrame, setupEditorPage } from '#tests/helpers/editor-history'
describe('undo/redo multi-step sequences', () => { describe('undo/redo multi-step sequences', () => {
@ -8,7 +9,7 @@ describe('undo/redo multi-step sequences', () => {
const frame = createHistoryFrame(editor, pageId, { x: 100, y: 100 }) const frame = createHistoryFrame(editor, pageId, { x: 100, y: 100 })
editor.select([frame.id]) editor.select([frame.id])
const createSnapshot = structuredClone(editor.graph.getNode(frame.id)!) const createSnapshot = structuredClone(getNodeOrThrow(editor.graph, frame.id))
editor.pushUndoEntry({ editor.pushUndoEntry({
label: 'Create', label: 'Create',
forward: () => { forward: () => {
@ -21,13 +22,13 @@ describe('undo/redo multi-step sequences', () => {
editor.graph.updateNode(frame.id, { x: 300, y: 50 }) editor.graph.updateNode(frame.id, { x: 300, y: 50 })
editor.commitMove(new Map([[frame.id, { x: 100, y: 100 }]])) editor.commitMove(new Map([[frame.id, { x: 100, y: 100 }]]))
expect(editor.graph.getNode(frame.id)!.x).toBe(300) expect(getNodeOrThrow(editor.graph, frame.id).x).toBe(300)
expect(editor.graph.getNode(frame.id)!.y).toBe(50) expect(getNodeOrThrow(editor.graph, frame.id).y).toBe(50)
editor.duplicateSelected() editor.duplicateSelected()
const dupIds = [...editor.state.selectedIds] const dupIds = [...editor.state.selectedIds]
expect(dupIds).toHaveLength(1) expect(dupIds).toHaveLength(1)
const dupId = dupIds[0] const dupId = expectDefined(dupIds[0], 'duplicate id')
expect(dupId).not.toBe(frame.id) expect(dupId).not.toBe(frame.id)
editor.graph.updateNode(dupId, { x: 500, y: 200 }) editor.graph.updateNode(dupId, { x: 500, y: 200 })
@ -35,7 +36,7 @@ describe('undo/redo multi-step sequences', () => {
// Undo move copy // Undo move copy
editor.undo.undo() editor.undo.undo()
expect(editor.graph.getNode(dupId)!.x).toBe(320) expect(getNodeOrThrow(editor.graph, dupId).x).toBe(320)
// Undo duplicate // Undo duplicate
editor.undo.undo() editor.undo.undo()
@ -43,7 +44,7 @@ describe('undo/redo multi-step sequences', () => {
// Undo move // Undo move
editor.undo.undo() editor.undo.undo()
expect(editor.graph.getNode(frame.id)!.x).toBe(100) expect(getNodeOrThrow(editor.graph, frame.id).x).toBe(100)
// Undo create // Undo create
editor.undo.undo() editor.undo.undo()
@ -52,11 +53,11 @@ describe('undo/redo multi-step sequences', () => {
// Redo create // Redo create
editor.undo.redo() editor.undo.redo()
expect(editor.graph.getNode(frame.id)).not.toBeUndefined() expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
expect(editor.graph.getNode(frame.id)!.x).toBe(100) expect(getNodeOrThrow(editor.graph, frame.id).x).toBe(100)
// Redo move // Redo move
editor.undo.redo() editor.undo.redo()
expect(editor.graph.getNode(frame.id)!.x).toBe(300) expect(getNodeOrThrow(editor.graph, frame.id).x).toBe(300)
// Redo duplicate — must recreate with SAME ID // Redo duplicate — must recreate with SAME ID
editor.undo.redo() editor.undo.redo()
@ -64,8 +65,8 @@ describe('undo/redo multi-step sequences', () => {
// Redo move copy — must find the node by same ID // Redo move copy — must find the node by same ID
editor.undo.redo() editor.undo.redo()
expect(editor.graph.getNode(dupId)!.x).toBe(500) expect(getNodeOrThrow(editor.graph, dupId).x).toBe(500)
expect(editor.graph.getNode(dupId)!.y).toBe(200) expect(getNodeOrThrow(editor.graph, dupId).y).toBe(200)
}) })
test('duplicate with children preserves subtree on redo', () => { test('duplicate with children preserves subtree on redo', () => {
@ -84,11 +85,11 @@ describe('undo/redo multi-step sequences', () => {
editor.select([frame.id]) editor.select([frame.id])
editor.duplicateSelected() editor.duplicateSelected()
const dupFrameId = [...editor.state.selectedIds][0] const dupFrameId = expectDefined([...editor.state.selectedIds][0], 'duplicated frame id')
const dupChildren = editor.graph.getNode(dupFrameId)!.childIds const dupChildren = getNodeOrThrow(editor.graph, dupFrameId).childIds
expect(dupChildren).toHaveLength(1) expect(dupChildren).toHaveLength(1)
const dupTextId = dupChildren[0] const dupTextId = expectDefined(dupChildren[0], 'duplicated text id')
expect(editor.graph.getNode(dupTextId)!.text).toBe('Hello') expect(getNodeOrThrow(editor.graph, dupTextId).text).toBe('Hello')
// Undo // Undo
editor.undo.undo() editor.undo.undo()
@ -99,8 +100,8 @@ describe('undo/redo multi-step sequences', () => {
editor.undo.redo() editor.undo.redo()
expect(editor.graph.getNode(dupFrameId)).not.toBeUndefined() expect(editor.graph.getNode(dupFrameId)).not.toBeUndefined()
expect(editor.graph.getNode(dupTextId)).not.toBeUndefined() expect(editor.graph.getNode(dupTextId)).not.toBeUndefined()
expect(editor.graph.getNode(dupFrameId)!.childIds).toContain(dupTextId) expect(getNodeOrThrow(editor.graph, dupFrameId).childIds).toContain(dupTextId)
expect(editor.graph.getNode(dupTextId)!.text).toBe('Hello') expect(getNodeOrThrow(editor.graph, dupTextId).text).toBe('Hello')
}) })
test('page snapshot restore preserves node IDs', () => { test('page snapshot restore preserves node IDs', () => {
@ -118,7 +119,7 @@ describe('undo/redo multi-step sequences', () => {
expect(editor.graph.getNode(frame.id)).not.toBeUndefined() expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
expect(editor.graph.getNode(child.id)).not.toBeUndefined() expect(editor.graph.getNode(child.id)).not.toBeUndefined()
expect(editor.graph.getNode(frame.id)!.childIds).toEqual([child.id]) expect(getNodeOrThrow(editor.graph, frame.id).childIds).toEqual([child.id])
}) })
test('delete frame with children → undo restores subtree', () => { test('delete frame with children → undo restores subtree', () => {
@ -142,6 +143,6 @@ describe('undo/redo multi-step sequences', () => {
editor.undo.undo() editor.undo.undo()
expect(editor.graph.getNode(frame.id)).not.toBeUndefined() expect(editor.graph.getNode(frame.id)).not.toBeUndefined()
expect(editor.graph.getNode(child.id)).not.toBeUndefined() expect(editor.graph.getNode(child.id)).not.toBeUndefined()
expect(editor.graph.getNode(frame.id)!.childIds).toContain(child.id) expect(getNodeOrThrow(editor.graph, frame.id).childIds).toContain(child.id)
}) })
}) })