gongxiangp2/components/detail-generator/DetailCanvas.test.tsx

151 lines
7.4 KiB
TypeScript

import { render, waitFor } from '@testing-library/react'
import DetailCanvas from './DetailCanvas'
import { DETAIL_FONT_FAMILY, type DetailScreen } from '@/lib/detail-generator/types'
import { ensureDetailFontsLoaded } from '@/lib/detail-generator/fonts'
const mockTextboxes: { text: string; isEditing: boolean; hiddenTextarea: HTMLTextAreaElement | null; events: Record<string, () => void> }[] = []
const mockCanvases: unknown[] = []
let mockDisposePromise: Promise<unknown> = Promise.resolve(true)
jest.mock('@/lib/detail-generator/fonts', () => ({
...jest.requireActual('@/lib/detail-generator/fonts'),
ensureDetailFontsLoaded: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('fabric', () => {
class Textbox {
type = 'textbox'
text: string
isEditing = false
hiddenTextarea: HTMLTextAreaElement | null = null
events: Record<string, () => void> = {}
constructor(text: string, options: object) { this.text = text; Object.assign(this, options); mockTextboxes.push(this) }
on(name: string, callback: () => void) { this.events[name] = callback }
set(options: object) { Object.assign(this, options) }
exitEditing() { this.isEditing = false; this.events['editing:exited']?.() }
}
class Canvas {
objects: Textbox[] = []
element: HTMLCanvasElement
wrapper: HTMLDivElement
constructor(element: HTMLCanvasElement) {
this.element = element
this.wrapper = document.createElement('div')
this.wrapper.className = 'canvas-container'
element.parentNode!.insertBefore(this.wrapper, element)
this.wrapper.appendChild(element)
const upper = document.createElement('canvas')
upper.className = 'upper-canvas'
this.wrapper.appendChild(upper)
mockCanvases.push(this)
}
add(object: Textbox) { this.objects.push(object) }
getObjects() { return this.objects }
setDimensions() {}
setZoom() {}
requestRenderAll() {}
dispose() {
this.objects.forEach((object) => object.events['editing:exited']?.())
this.wrapper.replaceWith(this.element)
return mockDisposePromise
}
}
return { Canvas, Textbox }
})
const detailScreen: DetailScreen = {
id: 'screen-a', title: '原标题', body: '', imagePrompt: '', width: 750, height: 1200, backgroundColor: '#ffffff',
texts: [{ id: 'screen-a-title', text: '原标题', x: 40, y: 58, width: 670, fontSize: 48, lineHeight: 1.25, color: '#101828', fontFamily: DETAIL_FONT_FAMILY, align: 'left' }],
}
beforeEach(() => {
mockCanvases.length = 0; mockTextboxes.length = 0; mockDisposePromise = Promise.resolve(true)
global.ResizeObserver = class { observe() {}; disconnect() {}; unobserve() {} } as typeof ResizeObserver
jest.mocked(ensureDetailFontsLoaded).mockResolvedValue(undefined)
})
test('初始化错误保留可恢复提示和阶段诊断,不记录私有资源 URL', async () => {
const diagnostics = jest.spyOn(console, 'error').mockImplementation(() => undefined)
jest.mocked(ensureDetailFontsLoaded).mockRejectedValueOnce(new Error('Unable to load https://private.example.invalid/font?token=synthetic-token'))
render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={jest.fn()} />)
await waitFor(() => expect(diagnostics).toHaveBeenCalledWith('[DetailCanvas] preview failed', {
stage: 'load-fonts', name: 'Error', message: 'Unable to load [resource]',
}))
diagnostics.mockRestore()
})
test('结束未修改的文字编辑不会创建未保存草稿', async () => {
const onTextChange = jest.fn()
render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} />)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
mockTextboxes[0].events['editing:exited']()
expect(onTextChange).not.toHaveBeenCalled()
mockTextboxes[0].text = '真实修改'
mockTextboxes[0].events['editing:exited']()
expect(onTextChange).toHaveBeenCalledWith('screen-a-title', '真实修改')
})
test('正在画布中输入但尚未退出编辑时,已经启用未保存保护', async () => {
const onUnsavedEdit = jest.fn()
const onTextChange = jest.fn()
render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} onUnsavedEdit={onUnsavedEdit} />)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
mockTextboxes[0].events.changed()
expect(onUnsavedEdit).not.toHaveBeenCalled()
mockTextboxes[0].text = '尚未退出画布编辑的修改'
mockTextboxes[0].events.changed()
expect(onUnsavedEdit).toHaveBeenCalledTimes(1)
expect(onTextChange).not.toHaveBeenCalled()
})
test('点击画布外部操作前提交当前文案,避免保存旧文字', async () => {
const onTextChange = jest.fn()
render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} />)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
const textbox = mockTextboxes[0]
textbox.hiddenTextarea = document.createElement('textarea')
textbox.events['editing:entered']()
textbox.text = '即将保存的真实文案'
textbox.hiddenTextarea.dispatchEvent(new FocusEvent('blur'))
expect(onTextChange).toHaveBeenCalledWith('screen-a-title', '即将保存的真实文案')
})
test('没有 blur 的外部 click 也显式提交并退出原生文字编辑', async () => {
const onTextChange = jest.fn()
const { container } = render(<><DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} /><button type="button">外部保存</button></>)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
mockTextboxes[0].text = '点击外部前的实际文本'
mockTextboxes[0].isEditing = true
container.querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
expect(onTextChange).toHaveBeenCalledWith('screen-a-title', '点击外部前的实际文本')
expect(mockTextboxes[0].isEditing).toBe(false)
})
test('画布重建清理不会回写文字,且等待旧画布释放后才初始化', async () => {
const onTextChange = jest.fn()
let finishDisposal!: () => void
mockDisposePromise = new Promise<void>((resolve) => { finishDisposal = resolve })
const { rerender } = render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={onTextChange} />)
await waitFor(() => expect(mockTextboxes).toHaveLength(1))
mockTextboxes[0].text = '清理中的临时状态'
rerender(<DetailCanvas screen={{ ...detailScreen, height: 1300 }} disabled={false} onTextChange={onTextChange} />)
expect(onTextChange).not.toHaveBeenCalled()
expect(mockCanvases).toHaveLength(1)
finishDisposal()
await waitFor(() => expect(mockCanvases).toHaveLength(2))
expect(onTextChange).not.toHaveBeenCalled()
})
test('Fabric 真实移动 Canvas DOM 后多次切屏不会与 React insertBefore 冲突', async () => {
const diagnostics = jest.spyOn(console, 'error').mockImplementation(() => undefined)
const { container, rerender } = render(<DetailCanvas screen={detailScreen} disabled={false} onTextChange={jest.fn()} />)
await waitFor(() => expect(container.querySelectorAll('.canvas-container')).toHaveLength(1))
for (const id of ['screen-b', 'screen-c', 'screen-a']) {
rerender(<DetailCanvas screen={{ ...detailScreen, id }} disabled={false} onTextChange={jest.fn()} />)
await waitFor(() => expect(container.querySelectorAll('.upper-canvas')).toHaveLength(1))
await waitFor(() => expect(container.querySelectorAll('[role="status"]')).toHaveLength(0))
expect(container.querySelectorAll('canvas')).toHaveLength(2)
}
expect(diagnostics).not.toHaveBeenCalled()
diagnostics.mockRestore()
})