405 lines
15 KiB
TypeScript
405 lines
15 KiB
TypeScript
/**
|
||
* report-designer-vue 组件测试。
|
||
*
|
||
* 验证:
|
||
* - 主布局挂载后渲染三栏结构(工具栏/工具箱/属性面板/状态栏/画布)。
|
||
* - 工具箱可新增元素并反映到画布。
|
||
* - 点击画布元素可选中,属性面板显示其信息;点击空白取消选中。
|
||
* - 工具栏撤销可用。
|
||
* - 画布缩放、页面尺寸、标尺刻度。
|
||
* - 拖拽移动元素,且连续拖拽合并为一步撤销。
|
||
* - 快捷键(Delete / Ctrl+Z),输入态下不触发。
|
||
* - 主题样式注入是幂等的。
|
||
*/
|
||
import { describe, expect, it, beforeEach } from "vitest"
|
||
import { mount, flushPromises } from "@vue/test-utils"
|
||
import { createDocument, type ReportDocument } from "@report/core"
|
||
import { mmToPx } from "@report/layout"
|
||
import ReportDesigner from "../src/ReportDesigner.vue"
|
||
import ReportToolbar from "../src/components/ReportToolbar.vue"
|
||
import ReportToolbox from "../src/components/ReportToolbox.vue"
|
||
import ReportCanvas from "../src/components/ReportCanvas.vue"
|
||
import ReportProperties from "../src/components/ReportProperties.vue"
|
||
import ReportStatusbar from "../src/components/ReportStatusbar.vue"
|
||
import { useEditor } from "../src/use-editor.js"
|
||
import { injectThemeStyle } from "../src/theme.js"
|
||
import { buildRulerTicks, chooseRulerStepMm } from "../src/ruler.js"
|
||
import { createEditorSession } from "@report/designer"
|
||
|
||
function sampleDoc(): ReportDocument {
|
||
const doc = createDocument({ name: "UI 测试" })
|
||
const detail = doc.sections.find((s) => s.kind === "detail")!
|
||
detail.children.push({
|
||
id: "t1",
|
||
type: "text",
|
||
frame: { x: 0, y: 0, width: 50, height: 8 },
|
||
props: { text: "标题" },
|
||
})
|
||
return doc
|
||
}
|
||
|
||
/** 取画布页面的实际宽度(px),从内联样式解析。 */
|
||
function pageWidthPx(wrapper: ReturnType<typeof mount>): number {
|
||
const style = wrapper.find(".rd-canvas-page").attributes("style") ?? ""
|
||
const match = /width:\s*([\d.]+)px/.exec(style)
|
||
return match ? Number(match[1]) : Number.NaN
|
||
}
|
||
|
||
/** 取画布 sizer 的宽度(px)。 */
|
||
function sizerWidthPx(wrapper: ReturnType<typeof mount>): number {
|
||
const style = wrapper.find(".rd-canvas-sizer").attributes("style") ?? ""
|
||
const match = /width:\s*([\d.]+)px/.exec(style)
|
||
return match ? Number(match[1]) : Number.NaN
|
||
}
|
||
|
||
/** 在工具栏里按 aria-label 找按钮。 */
|
||
function toolbarButton(wrapper: ReturnType<typeof mount>, label: string) {
|
||
return wrapper
|
||
.findComponent(ReportToolbar)
|
||
.findAll("button")
|
||
.find((b) => b.attributes("aria-label") === label)!
|
||
}
|
||
|
||
describe("ReportDesigner 主布局", () => {
|
||
it("挂载后渲染三栏结构与五个区域", () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
expect(wrapper.findComponent(ReportToolbar).exists()).toBe(true)
|
||
expect(wrapper.findComponent(ReportToolbox).exists()).toBe(true)
|
||
expect(wrapper.findComponent(ReportCanvas).exists()).toBe(true)
|
||
expect(wrapper.findComponent(ReportProperties).exists()).toBe(true)
|
||
expect(wrapper.findComponent(ReportStatusbar).exists()).toBe(true)
|
||
})
|
||
|
||
it("画布渲染文档中的元素", () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
const canvas = wrapper.findComponent(ReportCanvas)
|
||
expect(canvas.findAll(".rd-canvas-element").length).toBe(1)
|
||
expect(canvas.text()).toContain("标题")
|
||
})
|
||
|
||
it("点击画布元素后属性面板显示信息", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
const element = wrapper.find(".rd-canvas-element")
|
||
await element.trigger("mousedown")
|
||
await flushPromises()
|
||
|
||
const properties = wrapper.findComponent(ReportProperties)
|
||
expect(properties.text()).toContain("t1")
|
||
expect(properties.text()).toContain("text")
|
||
|
||
// mousedown 会挂上全局拖拽监听,测试结束必须卸载以解绑。
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it("属性面板可编辑文本内容", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
await wrapper.find(".rd-canvas-element").trigger("mousedown")
|
||
await flushPromises()
|
||
|
||
const input = wrapper.find(".rd-prop-input")
|
||
expect(input.exists()).toBe(true)
|
||
await input.setValue("改后的标题")
|
||
await flushPromises()
|
||
|
||
// 画布同步显示新文本
|
||
expect(wrapper.findComponent(ReportCanvas).text()).toContain("改后的标题")
|
||
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it("工具箱新增文本元素并出现在画布", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
const before = wrapper.findAll(".rd-canvas-element").length
|
||
|
||
const toolbox = wrapper.findComponent(ReportToolbox)
|
||
await toolbox.find("button").trigger("click")
|
||
await flushPromises()
|
||
|
||
const after = wrapper.findAll(".rd-canvas-element").length
|
||
expect(after).toBe(before + 1)
|
||
})
|
||
|
||
it("工具栏撤销新增操作", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
const before = wrapper.findAll(".rd-canvas-element").length
|
||
|
||
await wrapper.findComponent(ReportToolbox).find("button").trigger("click")
|
||
await flushPromises()
|
||
expect(wrapper.findAll(".rd-canvas-element").length).toBe(before + 1)
|
||
|
||
const toolbar = wrapper.findComponent(ReportToolbar)
|
||
const undoBtn = toolbar.findAll("button").find((b) => b.attributes("aria-label") === "撤销")!
|
||
await undoBtn.trigger("click")
|
||
await flushPromises()
|
||
|
||
expect(wrapper.findAll(".rd-canvas-element").length).toBe(before)
|
||
})
|
||
|
||
it("无选中时属性面板显示空态", () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
const properties = wrapper.findComponent(ReportProperties)
|
||
expect(properties.text()).toContain("未选中元素")
|
||
})
|
||
})
|
||
|
||
describe("useEditor composable", () => {
|
||
it("select 与 move 反映到状态", () => {
|
||
const doc = sampleDoc()
|
||
const session = createEditorSession(doc)
|
||
const editor = useEditor(session)
|
||
|
||
editor.select("t1")
|
||
expect(editor.state.selectedIds).toContain("t1")
|
||
|
||
editor.move({ t1: { x: 20, y: 30, width: 50, height: 8 } })
|
||
const moved = editor.state.document.sections
|
||
.find((s) => s.kind === "detail")!.children[0]!.frame
|
||
expect(moved.x).toBe(20)
|
||
expect(moved.y).toBe(30)
|
||
})
|
||
|
||
it("undo 后文档恢复", () => {
|
||
const doc = sampleDoc()
|
||
const session = createEditorSession(doc)
|
||
const editor = useEditor(session)
|
||
|
||
editor.move({ t1: { x: 99, y: 99, width: 50, height: 8 } })
|
||
editor.undo()
|
||
const restored = editor.state.document.sections
|
||
.find((s) => s.kind === "detail")!.children[0]!.frame
|
||
expect(restored.x).toBe(0)
|
||
})
|
||
})
|
||
|
||
describe("主题注入", () => {
|
||
beforeEach(() => {
|
||
document.head.innerHTML = ""
|
||
})
|
||
|
||
it("注入主题样式到 head", () => {
|
||
injectThemeStyle()
|
||
const style = document.getElementById("rd-designer-theme")
|
||
expect(style).not.toBeNull()
|
||
expect(style!.textContent).toContain("--rd-background")
|
||
expect(style!.textContent).toContain(".dark")
|
||
})
|
||
|
||
it("重复注入是幂等的", () => {
|
||
injectThemeStyle()
|
||
injectThemeStyle()
|
||
const styles = document.querySelectorAll("#rd-designer-theme")
|
||
expect(styles.length).toBe(1)
|
||
})
|
||
})
|
||
|
||
describe("标尺刻度计算", () => {
|
||
it("步长随缩放自适应,保证刻度不会挤在一起", () => {
|
||
// mmToPx(1)=3.78px、2=7.56px、5=18.9px,阈值 8px
|
||
expect(chooseRulerStepMm(1)).toBe(5)
|
||
expect(chooseRulerStepMm(2)).toBe(2)
|
||
expect(chooseRulerStepMm(0.5)).toBe(5)
|
||
})
|
||
|
||
it("刻度从页面原点开始且不超出纸张长度", () => {
|
||
const ticks = buildRulerTicks(210, 1)
|
||
expect(ticks.length).toBeGreaterThan(0)
|
||
expect(ticks[0]!.mm).toBe(0)
|
||
expect(ticks[0]!.px).toBe(0)
|
||
|
||
const last = ticks[ticks.length - 1]!
|
||
expect(last.mm).toBeLessThanOrEqual(210)
|
||
// px 必须是 mm 换算后乘缩放的结果
|
||
expect(last.px).toBeCloseTo(mmToPx(last.mm) * 1, 6)
|
||
})
|
||
|
||
it("标签间距不小于 40px,避免数字重叠", () => {
|
||
const labeled = buildRulerTicks(210, 1).filter((t) => t.label !== undefined)
|
||
expect(labeled.length).toBeGreaterThan(1)
|
||
for (let i = 1; i < labeled.length; i += 1) {
|
||
expect(labeled[i]!.px - labeled[i - 1]!.px).toBeGreaterThanOrEqual(40)
|
||
}
|
||
})
|
||
|
||
it("放大后刻度更密", () => {
|
||
expect(buildRulerTicks(210, 2).length).toBeGreaterThan(
|
||
buildRulerTicks(210, 1).length,
|
||
)
|
||
})
|
||
|
||
it("非法输入返回空数组", () => {
|
||
expect(buildRulerTicks(0, 1)).toEqual([])
|
||
expect(buildRulerTicks(210, 0)).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe("标尺渲染", () => {
|
||
it("画布渲染水平与垂直标尺及刻度", () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
expect(wrapper.find(".rd-canvas-ruler-h").exists()).toBe(true)
|
||
expect(wrapper.find(".rd-canvas-ruler-v").exists()).toBe(true)
|
||
expect(wrapper.findAll(".rd-ruler-tick").length).toBeGreaterThan(0)
|
||
expect(wrapper.findAll(".rd-ruler-label").length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it("标尺随滚动同步偏移", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
const viewport = wrapper.find(".rd-canvas-viewport")
|
||
// jsdom 没有真实布局,直接造一个滚动量再派发 scroll。
|
||
Object.defineProperty(viewport.element, "scrollLeft", {
|
||
value: 40,
|
||
configurable: true,
|
||
})
|
||
await viewport.trigger("scroll")
|
||
|
||
// 轨道偏移 = 纸张起点(24) − 滚动量(40) = −16
|
||
const track = wrapper.find(".rd-canvas-ruler-h .rd-ruler-track")
|
||
expect(track.attributes("style")).toContain("left: -16px")
|
||
})
|
||
})
|
||
|
||
describe("画布页面尺寸与缩放", () => {
|
||
it("页面尺寸取自文档页面设置(A4 竖版)", () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
expect(pageWidthPx(wrapper)).toBeCloseTo(mmToPx(210), 3)
|
||
})
|
||
|
||
it("换成 A5 后页面尺寸随之变化", () => {
|
||
const doc = sampleDoc()
|
||
doc.page.paper = "A5"
|
||
const wrapper = mount(ReportDesigner, { props: { document: doc } })
|
||
expect(pageWidthPx(wrapper)).toBeCloseTo(mmToPx(148), 3)
|
||
})
|
||
|
||
it("缩放真实作用于页面(transform),并撑开滚动区域", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
expect(wrapper.find(".rd-canvas-page").attributes("style")).toContain("scale(1)")
|
||
const beforeSizer = sizerWidthPx(wrapper)
|
||
|
||
await toolbarButton(wrapper, "放大").trigger("click")
|
||
await flushPromises()
|
||
|
||
// 回归:旧实现只改状态栏数字,页面 transform 不变
|
||
expect(wrapper.find(".rd-canvas-page").attributes("style")).toContain("scale(1.25)")
|
||
expect(sizerWidthPx(wrapper)).toBeGreaterThan(beforeSizer)
|
||
})
|
||
})
|
||
|
||
describe("画布拖拽", () => {
|
||
/** 直接挂 ReportCanvas,便于从 session 读取拖拽结果。 */
|
||
function mountCanvas() {
|
||
const doc = sampleDoc()
|
||
const session = createEditorSession(doc)
|
||
const editor = useEditor(session)
|
||
const wrapper = mount(ReportCanvas, { props: { editor } })
|
||
return { wrapper, session, editor }
|
||
}
|
||
|
||
it("拖拽改变元素 frame,结束后退出拖拽态", async () => {
|
||
const { wrapper, session, editor } = mountCanvas()
|
||
|
||
await wrapper.find(".rd-canvas-element").trigger("mousedown", {
|
||
clientX: 100,
|
||
clientY: 100,
|
||
})
|
||
expect(editor.isDragging()).toBe(true)
|
||
|
||
// 屏幕位移 96px = 25.4mm,吸附到整数 mm → 25
|
||
document.dispatchEvent(new MouseEvent("mousemove", { clientX: 196, clientY: 100 }))
|
||
await flushPromises()
|
||
|
||
const frame = session
|
||
.getDocument()
|
||
.sections.find((s) => s.kind === "detail")!.children[0]!.frame
|
||
expect(frame.x).toBe(25)
|
||
expect(frame.y).toBe(0)
|
||
|
||
document.dispatchEvent(new MouseEvent("mouseup"))
|
||
await flushPromises()
|
||
expect(editor.isDragging()).toBe(false)
|
||
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it("一次拖拽的多次位移合并为一步撤销", async () => {
|
||
const { wrapper, session } = mountCanvas()
|
||
const detail = () => session.getDocument().sections.find((s) => s.kind === "detail")!
|
||
|
||
await wrapper.find(".rd-canvas-element").trigger("mousedown", {
|
||
clientX: 100,
|
||
clientY: 100,
|
||
})
|
||
// 模拟拖拽过程中的多次 mousemove
|
||
for (const x of [130, 160, 196]) {
|
||
document.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: 100 }))
|
||
}
|
||
document.dispatchEvent(new MouseEvent("mouseup"))
|
||
await flushPromises()
|
||
|
||
expect(detail().children[0]!.frame.x).toBe(25)
|
||
|
||
// 依赖 moveElementsCommand 的 mergeKey,一次撤销即回到原位
|
||
session.undo()
|
||
expect(detail().children[0]!.frame.x).toBe(0)
|
||
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it("点击画布空白取消选中", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
await wrapper.find(".rd-canvas-element").trigger("mousedown")
|
||
await flushPromises()
|
||
expect(wrapper.findComponent(ReportProperties).text()).toContain("t1")
|
||
|
||
await wrapper.find(".rd-canvas-viewport").trigger("mousedown")
|
||
await flushPromises()
|
||
expect(wrapper.findComponent(ReportProperties).text()).toContain("未选中元素")
|
||
|
||
wrapper.unmount()
|
||
})
|
||
})
|
||
|
||
describe("快捷键", () => {
|
||
it("Delete 删除选中元素", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
await wrapper.find(".rd-canvas-element").trigger("mousedown")
|
||
await flushPromises()
|
||
expect(wrapper.findAll(".rd-canvas-element").length).toBe(1)
|
||
|
||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Delete" }))
|
||
await flushPromises()
|
||
expect(wrapper.findAll(".rd-canvas-element").length).toBe(0)
|
||
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it("Ctrl+Z 撤销", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
await wrapper.findComponent(ReportToolbox).find("button").trigger("click")
|
||
await flushPromises()
|
||
expect(wrapper.findAll(".rd-canvas-element").length).toBe(2)
|
||
|
||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "z", ctrlKey: true }))
|
||
await flushPromises()
|
||
expect(wrapper.findAll(".rd-canvas-element").length).toBe(1)
|
||
|
||
wrapper.unmount()
|
||
})
|
||
|
||
it("输入框内按 Delete 不删除画布元素", async () => {
|
||
const wrapper = mount(ReportDesigner, { props: { document: sampleDoc() } })
|
||
await wrapper.find(".rd-canvas-element").trigger("mousedown")
|
||
await flushPromises()
|
||
|
||
const input = wrapper.find(".rd-prop-input")
|
||
expect(input.exists()).toBe(true)
|
||
// 以输入框为 target 冒泡到 window,快捷键必须让位给打字
|
||
input.element.dispatchEvent(
|
||
new KeyboardEvent("keydown", { key: "Delete", bubbles: true }),
|
||
)
|
||
await flushPromises()
|
||
expect(wrapper.findAll(".rd-canvas-element").length).toBe(1)
|
||
|
||
wrapper.unmount()
|
||
})
|
||
})
|