openpencil/tests/engine/app/clipboard/memory.test.ts
Danila Poyarkov 8408b56721
refactor(app): modernize browser clipboard adapter (#601)
* refactor(app): isolate system clipboard adapters

- Split browser and Tauri clipboard behavior into focused adapters\n- Inject browser clipboard capabilities into unit-testable operations\n- Remove navigator and document mutation from headless clipboard tests

* refactor(app): delegate browser clipboard fallbacks

- Use copy-to-clipboard for modern rich MIME writes and execCommand fallback\n- Keep OpenPencil-specific HTML and plain-text payload construction at the adapter boundary\n- Remove hand-rolled browser capability and selection handling

* test(clipboard): verify rich browser menu round-trip

- Exercise copy from the browser Edit menu under a user gesture\n- Verify text/html and text/plain ClipboardItem formats\n- Paste the system clipboard payload back into the canvas

* refactor(clipboard): reduce adapter surface

- Expose only command dispatch and the injectable system clipboard contract\n- Keep browser and Tauri copy/paste operations private to their adapters\n- Rename the in-memory DataTransfer fallback and share design HTML recognition

* fix(clipboard): harden format and fallback handling

- Require complete OpenPencil or Figma clipboard markers\n- Await browser writes so adapter failures resolve false\n- Write plain-only Tauri payloads as text and reject unrelated clipboard text

* fix(clipboard): reject unrelated current browser data

* fix(clipboard): bind fallbacks to copied selection

- Match cached rich HTML to the current Tauri plain-text fallback\n- Preserve nodes when selection changes during an asynchronous cut\n- Reject unrelated current browser HTML before consulting memory

* fix(clipboard): reuse the shared memory payload type

* fix(clipboard): scan design markers linearly

* fix(clipboard): distinguish unavailable and empty reads

* style(clipboard): use includes for marker closure

* test(clipboard): avoid wall-clock marker assertions
2026-08-28 14:37:42 +03:00

271 lines
8.9 KiB
TypeScript

import { beforeEach, describe, expect, mock, test } from 'bun:test'
import type { Vector } from '@open-pencil/scene-graph/primitives'
import {
clearInMemoryClipboardHTML,
getInMemoryClipboardHTML,
hasInMemoryClipboardHTML,
setInMemoryClipboardHTML
} from '@/app/editor/clipboard/memory'
import { pasteClipboardToReplace } from '@/app/editor/clipboard/paste-to-replace'
import { executeClipboardCommand, type SystemClipboard } from '@/app/editor/clipboard/system'
import {
createBrowserSystemClipboard,
type BrowserClipboardIO
} from '@/app/editor/clipboard/system/browser'
import type { ClipboardPayload } from '@/app/editor/clipboard/system/types'
import { createEditorStore } from '@/app/editor/session/create'
import { toast } from '@/app/shell/ui'
beforeEach(() => {
clearInMemoryClipboardHTML()
toast.toasts.value = []
})
const unavailableClipboard: SystemClipboard = {
copy: async () => false,
paste: async () => false
}
const memoryIO: BrowserClipboardIO = {
write: async () => true,
readHTML: async () => ({ available: false })
}
const memoryClipboard = createBrowserSystemClipboard(memoryIO)
describe('in-memory clipboard', () => {
test('stores, retrieves, and clears clipboard HTML', () => {
expect(hasInMemoryClipboardHTML()).toBe(false)
expect(getInMemoryClipboardHTML()).toBe('')
const sampleHTML = '<!--(openpencil)test-->'
setInMemoryClipboardHTML(sampleHTML)
expect(getInMemoryClipboardHTML('unrelated')).toBe('')
expect(hasInMemoryClipboardHTML()).toBe(true)
expect(getInMemoryClipboardHTML()).toBe(sampleHTML)
clearInMemoryClipboardHTML()
expect(hasInMemoryClipboardHTML()).toBe(false)
expect(getInMemoryClipboardHTML()).toBe('')
})
test('browser clipboard delegates the rich payload to its I/O boundary', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Copy Target',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
const write = mock(async (_payload: ClipboardPayload) => true)
const clipboard = createBrowserSystemClipboard({
write,
readHTML: async () => ({ available: false })
})
const success = await clipboard.copy(store)
expect(success).toBe(true)
expect(write).toHaveBeenCalledTimes(1)
const payload = write.mock.calls[0]?.[0]
expect(payload?.html).toBeDefined()
expect(payload?.plainText).toBeDefined()
expect(hasInMemoryClipboardHTML()).toBe(true)
})
test('browser clipboard returns false when its writer fails', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Copy Target',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
const clipboard = createBrowserSystemClipboard({
write: async () => false,
readHTML: async () => ({ available: false })
})
const success = await clipboard.copy(store)
expect(success).toBe(false)
})
test('browser clipboard does not paste cached design data over unrelated current HTML', async () => {
setInMemoryClipboardHTML('<!--(openpencil)cached(/openpencil)-->')
const store = createEditorStore()
const paste = mock(async () => undefined)
store.pasteFromHTML = paste
const clipboard = createBrowserSystemClipboard({
write: async () => true,
readHTML: async () => ({ available: true, html: '<p>ordinary current clipboard</p>' })
})
expect(await clipboard.paste(store)).toBe(false)
expect(paste).not.toHaveBeenCalled()
})
test('browser clipboard rejects a successful plain-text-only read over stale memory', async () => {
setInMemoryClipboardHTML('<!--(openpencil)cached(/openpencil)-->', 'cached')
const store = createEditorStore()
const paste = mock(async () => undefined)
store.pasteFromHTML = paste
const clipboard = createBrowserSystemClipboard({
write: async () => true,
readHTML: async () => ({ available: true, html: null })
})
expect(await clipboard.paste(store)).toBe(false)
expect(paste).not.toHaveBeenCalled()
})
test('executeClipboardCommand cut does not delete nodes when clipboard copy fails', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Safe Rect',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
const cutOk = await executeClipboardCommand(store, 'cut', undefined, unavailableClipboard)
expect(cutOk).toBe(false)
expect(store.graph.getNode(rect.id)).toBeDefined()
})
test('executeClipboardCommand cut preserves a changed selection while copy is pending', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const first = store.graph.createNode('RECTANGLE', pageId, { name: 'First' })
const second = store.graph.createNode('RECTANGLE', pageId, { name: 'Second' })
store.select([first.id])
const copying = Promise.withResolvers<boolean>()
const clipboard: SystemClipboard = {
copy: () => copying.promise,
paste: async () => false
}
const cutting = executeClipboardCommand(store, 'cut', undefined, clipboard)
store.select([second.id])
copying.resolve(true)
expect(await cutting).toBe(false)
expect(store.graph.getNode(first.id)).toBeDefined()
expect(store.graph.getNode(second.id)).toBeDefined()
})
test('pasteToReplace uses in-memory clipboard when system clipboard is unavailable', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const target = store.graph.createNode('RECTANGLE', pageId, {
name: 'Target',
x: 10,
y: 10,
width: 100,
height: 100
})
store.select([target.id])
// Copy target (populates in-memory clipboard)
await executeClipboardCommand(store, 'copy', undefined, memoryClipboard)
expect(hasInMemoryClipboardHTML()).toBe(true)
// Create another node to replace
const replaceTarget = store.graph.createNode('RECTANGLE', pageId, {
name: 'To Replace',
x: 50,
y: 50,
width: 80,
height: 80
})
store.select([replaceTarget.id])
// Run pasteClipboardToReplace
await pasteClipboardToReplace(store)
// Verify replace succeeded without toast errors
expect(toast.toasts.value).toHaveLength(0)
expect(store.graph.getNode(replaceTarget.id)).toBeUndefined()
})
test('executeClipboardCommand cut deletes selection and returns true when copy succeeds', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Cut Target',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
const cutOk = await executeClipboardCommand(store, 'cut', undefined, memoryClipboard)
expect(cutOk).toBe(true)
expect(store.graph.getNode(rect.id)).toBeUndefined()
})
test('executeClipboardCommand paste forwards cursorPos to store.pasteFromHTML', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Source',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
await executeClipboardCommand(store, 'copy', undefined, memoryClipboard)
let receivedCursorPos: Vector | undefined
const originalPaste = store.pasteFromHTML.bind(store)
store.pasteFromHTML = async (html, cursorPos, options) => {
receivedCursorPos = cursorPos
return originalPaste(html, cursorPos, options)
}
const cursorPos: Vector = { x: 150, y: 250 }
const pasteOk = await executeClipboardCommand(store, 'paste', cursorPos, memoryClipboard)
expect(pasteOk).toBe(true)
expect(receivedCursorPos).toEqual(cursorPos)
})
test('executeClipboardCommand paste falls back to memory clipboard', async () => {
const store = createEditorStore()
const pageId = store.state.currentPageId
const rect = store.graph.createNode('RECTANGLE', pageId, {
name: 'Source Rect',
x: 0,
y: 0,
width: 50,
height: 50
})
store.select([rect.id])
await executeClipboardCommand(store, 'copy', undefined, memoryClipboard)
expect(hasInMemoryClipboardHTML()).toBe(true)
const pasteOk = await executeClipboardCommand(store, 'paste', undefined, memoryClipboard)
expect(pasteOk).toBe(true)
// An additional node should have been pasted
const selected = [...store.state.selectedIds]
expect(selected).toHaveLength(1)
expect(selected[0]).not.toBe(rect.id)
const pastedNode = store.graph.getNode(selected[0])
expect(pastedNode?.name).toBe('Source Rect')
})
})