151 lines
7.4 KiB
TypeScript
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()
|
|
})
|