390 lines
14 KiB
TypeScript
390 lines
14 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: Promise<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 = await write.mock.calls[0]?.[0]
|
|
expect(payload?.html).toBeDefined()
|
|
expect(payload?.plainText).toBeDefined()
|
|
expect(hasInMemoryClipboardHTML()).toBe(true)
|
|
})
|
|
|
|
test('starts the writer before serialization completes', async () => {
|
|
const store = createEditorStore()
|
|
store.select([store.graph.createNode('RECTANGLE', store.state.currentPageId).id])
|
|
const prepared = Promise.withResolvers<ClipboardPayload>()
|
|
store.prepareCopy = () => prepared.promise
|
|
let writerStarted = false
|
|
const clipboard = createBrowserSystemClipboard({
|
|
write: async (payload) => {
|
|
writerStarted = true
|
|
await payload
|
|
return true
|
|
},
|
|
readHTML: async () => ({ available: false })
|
|
})
|
|
const copying = clipboard.copy(store)
|
|
expect(writerStarted).toBe(true)
|
|
prepared.resolve({ html: '<p>Prepared</p>', plainText: 'Prepared' })
|
|
expect(await copying).toBe(true)
|
|
})
|
|
|
|
test('reports serialization failure without deleting or claiming success', async () => {
|
|
const store = createEditorStore()
|
|
const node = store.graph.createNode('RECTANGLE', store.state.currentPageId)
|
|
store.select([node.id])
|
|
store.prepareCopy = async () => {
|
|
throw new Error('Encoding failed')
|
|
}
|
|
const clipboard = createBrowserSystemClipboard({
|
|
write: async (payload) => {
|
|
await payload
|
|
return true
|
|
},
|
|
readHTML: async () => ({ available: false })
|
|
})
|
|
expect(await executeClipboardCommand(store, 'cut', undefined, clipboard)).toBe(false)
|
|
expect(store.graph.getNode(node.id)).toBeDefined()
|
|
})
|
|
|
|
test('matching internal snapshots preserve geometry buffers and text sizing', async () => {
|
|
const store = createEditorStore()
|
|
const node = store.graph.createNode('TEXT', store.state.currentPageId, {
|
|
name: 'Snapshot source',
|
|
text: 'Hello',
|
|
textAutoResize: 'WIDTH_AND_HEIGHT',
|
|
fillGeometry: [{ windingRule: 'NONZERO', commandsBlob: new Uint8Array([0, 1, 2, 3]) }]
|
|
})
|
|
store.select([node.id])
|
|
expect(await memoryClipboard.copy(store)).toBe(true)
|
|
expect(await memoryClipboard.paste(store)).toBe(true)
|
|
const pasted = store.graph.getNode([...store.state.selectedIds][0])
|
|
expect(pasted?.id).not.toBe(node.id)
|
|
expect(pasted?.textAutoResize).toBe('WIDTH_AND_HEIGHT')
|
|
expect(pasted?.fillGeometry[0].commandsBlob).toBeInstanceOf(Uint8Array)
|
|
expect(pasted?.fillGeometry[0].commandsBlob).toEqual(node.fillGeometry[0].commandsBlob)
|
|
expect(pasted?.fillGeometry[0].commandsBlob).not.toBe(node.fillGeometry[0].commandsBlob)
|
|
})
|
|
|
|
test('mobile clipboard shares the typed snapshot across editor sessions', async () => {
|
|
const source = createEditorStore()
|
|
const node = source.graph.createNode('TEXT', source.state.currentPageId, {
|
|
name: 'Mobile source',
|
|
text: 'Hello',
|
|
textAutoResize: 'WIDTH_AND_HEIGHT'
|
|
})
|
|
source.select([node.id])
|
|
expect(await source.mobileCopy()).toBe(true)
|
|
const target = createEditorStore()
|
|
await target.mobilePaste()
|
|
const pasted = target.graph.getNode([...target.state.selectedIds][0])
|
|
expect(pasted?.name).toBe('Mobile source')
|
|
expect(pasted?.textAutoResize).toBe('WIDTH_AND_HEIGHT')
|
|
})
|
|
|
|
test('does not use a stale snapshot for different valid clipboard HTML', async () => {
|
|
const source = createEditorStore()
|
|
const original = source.graph.createNode('TEXT', source.state.currentPageId, {
|
|
name: 'Snapshot only',
|
|
textAutoResize: 'WIDTH_AND_HEIGHT'
|
|
})
|
|
source.select([original.id])
|
|
expect(await memoryClipboard.copy(source)).toBe(true)
|
|
const staleHTML = getInMemoryClipboardHTML()
|
|
const external = createEditorStore()
|
|
const rectangle = external.graph.createNode('RECTANGLE', external.state.currentPageId, {
|
|
name: 'External'
|
|
})
|
|
external.select([rectangle.id])
|
|
const differentHTML = (await external.prepareCopy()).html
|
|
expect(differentHTML).not.toBe(staleHTML)
|
|
const target = createEditorStore()
|
|
await target.pasteFromHTML(differentHTML)
|
|
expect([...target.state.selectedIds].map((id) => target.graph.getNode(id)?.name)).toEqual([
|
|
'External'
|
|
])
|
|
})
|
|
|
|
test('failed writes clear the previous in-memory snapshot', async () => {
|
|
const first = createEditorStore()
|
|
const firstNode = first.graph.createNode('RECTANGLE', first.state.currentPageId, {
|
|
name: 'Successful'
|
|
})
|
|
first.select([firstNode.id])
|
|
expect(await memoryClipboard.copy(first)).toBe(true)
|
|
expect(getInMemoryClipboardHTML()).not.toBe('')
|
|
const second = createEditorStore()
|
|
second.select([
|
|
second.graph.createNode('RECTANGLE', second.state.currentPageId, { name: 'Failed' }).id
|
|
])
|
|
const failing = createBrowserSystemClipboard({
|
|
write: async (payload) => {
|
|
await payload
|
|
return false
|
|
},
|
|
readHTML: async () => ({ available: false })
|
|
})
|
|
expect(await failing.copy(second)).toBe(false)
|
|
expect(getInMemoryClipboardHTML()).toBe('')
|
|
})
|
|
|
|
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 the matching snapshot', 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.pasteSnapshot.bind(store)
|
|
store.pasteSnapshot = async (snapshot, cursorPos, options) => {
|
|
receivedCursorPos = cursorPos
|
|
return originalPaste(snapshot, 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')
|
|
})
|
|
})
|