Files
workspace/code/one-designer/packages/report-designer-vue/tests/designer-vue.test.ts
T
2026-09-20 17:34:40 +08:00

405 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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()
})
})