20260920173439

This commit is contained in:
oneao committed 2026-09-20 17:34:40 +08:00
1 parent f4bfc1ddfc
commit 6f03d92425
174 files changed
+23490 -157

No files matched your search

@@ -0,0 +1,404 @@
/**
* 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()
})
})