/** * 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): 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): 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, 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() }) })