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

357 lines
12 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.
/**
* Designer 编辑器测试(对应设计文档 §4.5)。
*
* 核心断言:
* - 编辑器状态(选中、缩放、平移)独立于文档(§3.2)。
* - 所有编辑操作通过命令,可撤销可重做。
* - 坐标转换在屏幕 px 与文档 mm 之间正确换算。
*/
import { describe, expect, it, vi } from "vitest"
import { createDocument, createElementId, type ReportDocument } from "@report/core"
import { EditorSession, createEditorSession } from "../src/editor.js"
import { screenToDoc, docToScreen, hitTest, clampZoom, snapToGrid } from "../src/coords.js"
import { createDesignerState } from "../src/state.js"
import { createTextElement, createRectElement } from "../src/commands.js"
function sampleDoc(): ReportDocument {
const doc = createDocument({ name: "编辑器测试" })
const detail = doc.sections.find((s) => s.kind === "detail")!
detail.children.push(
createTextElement({ x: 0, y: 0, width: 50, height: 8 }, "标题"),
createRectElement({ x: 0, y: 10, width: 50, height: 20 }),
)
return doc
}
describe("编辑器状态独立于文档", () => {
it("选中状态不写入文档", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = doc.sections.find((s) => s.kind === "detail")!
const firstId = detail.children[0]!.id
editor.select(firstId)
// 文档序列化后不应包含任何选中状态
const serialized = JSON.stringify(editor.getDocument())
expect(serialized).not.toContain("selectedIds")
expect(editor.getSelectedIds()).toEqual([firstId])
})
it("缩放与平移不写入文档", () => {
const editor = new EditorSession(sampleDoc())
editor.setZoom(1.5)
editor.pan(10, 20)
const state = editor.getState()
expect(state.zoom).toBe(1.5)
expect(state.panX).toBe(10)
expect(state.panY).toBe(20)
// 文档本身不含这些字段
expect(JSON.stringify(editor.getDocument())).not.toContain("zoom")
})
})
describe("选择", () => {
it("选择单个元素", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = doc.sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
editor.select(id)
expect(editor.getSelectedIds()).toEqual([id])
})
it("多选与清空", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = doc.sections.find((s) => s.kind === "detail")!
const ids = detail.children.map((c) => c.id)
editor.selectMany(ids)
expect(editor.getSelectedIds().length).toBe(2)
editor.clearSelection()
expect(editor.getSelectedIds()).toHaveLength(0)
})
})
describe("编辑命令与撤销重做", () => {
it("移动元素可撤销", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = doc.sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
const before = { ...detail.children[0]!.frame }
editor.select(id)
editor.moveElements({ [id]: { x: 100, y: 100, width: 50, height: 8 } })
const afterMove = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(afterMove.x).toBe(100)
editor.undo()
const afterUndo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(afterUndo.x).toBe(before.x)
editor.redo()
const afterRedo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(afterRedo.x).toBe(100)
})
it("新增元素可撤销", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const beforeCount = detail.children.length
const newEl = createTextElement({ x: 0, y: 0, width: 30, height: 8 }, "新元素")
editor.addElement(detail.id, newEl)
const afterAdd = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length
expect(afterAdd).toBe(beforeCount + 1)
editor.undo()
const afterUndo = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length
expect(afterUndo).toBe(beforeCount)
})
it("删除选中元素", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const beforeCount = detail.children.length
const id = detail.children[0]!.id
editor.select(id)
editor.deleteSelected()
const afterDelete = editor.getDocument().sections.find((s) => s.kind === "detail")!.children.length
expect(afterDelete).toBe(beforeCount - 1)
expect(editor.getSelectedIds()).toHaveLength(0)
})
it("修改元素属性", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
editor.updateElement(id, "props.text", "改后的标题")
const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!
expect((after.props as { text: string }).text).toBe("改后的标题")
})
})
describe("坐标转换", () => {
it("屏幕 px 与文档 mm 换算(zoom=1, pan=0)", () => {
const state = createDesignerState()
const doc = screenToDoc({ x: 96, y: 96 }, state)
// 96px = 1in = 25.4mm
expect(doc.x).toBeCloseTo(25.4, 2)
expect(doc.y).toBeCloseTo(25.4, 2)
const screen = docToScreen({ x: 25.4, y: 25.4 }, state)
expect(screen.x).toBeCloseTo(96, 2)
expect(screen.y).toBeCloseTo(96, 2)
})
it("缩放影响换算", () => {
const state = createDesignerState({ zoom: 2 })
const doc = screenToDoc({ x: 96, y: 0 }, state)
// 96px / 2 = 48px = 12.7mm
expect(doc.x).toBeCloseTo(12.7, 2)
})
it("命中测试", () => {
const state = createDesignerState()
const frame = { x: 0, y: 0, width: 25.4, height: 25.4 }
// 中心点 (48,48)px = 12.7mm 在 frame 内
expect(hitTest({ x: 48, y: 48 }, frame, state)).toBe(true)
// 外部点不在
expect(hitTest({ x: 200, y: 200 }, frame, state)).toBe(false)
})
it("缩放钳制在 [0.5, 2]", () => {
expect(clampZoom(0.1)).toBe(0.5)
expect(clampZoom(5)).toBe(2)
expect(clampZoom(1)).toBe(1)
})
})
describe("拖拽", () => {
it("拖拽移动选中元素", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
const before = { ...detail.children[0]!.frame }
editor.select(id)
editor.beginDrag(0, 0)
editor.updateDrag(10, 20)
editor.endDrag()
const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
expect(after.x).toBe(before.x + 10)
expect(after.y).toBe(before.y + 20)
expect(editor.isDragging()).toBe(false)
})
it("吸附作用在结果坐标上,拖动落点为整数 mm", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
editor.select(id)
editor.beginDrag(0, 0)
editor.updateDrag(10.4, 20.6)
editor.endDrag()
const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
// 起点 x=0 y=0,位移取整后应为 10 / 21
expect(after.x).toBe(10)
expect(after.y).toBe(21)
})
it("吸附取整的是落点而非位移量", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[1]!.id
// 关键:起点必须是非整数,否则「取整落点」与「取整位移量」结果相同,
// 这个用例就区分不出两种实现。
editor.moveElements({ [id]: { x: 5.4, y: 10.4, width: 50, height: 20 } })
editor.select(id)
editor.beginDrag(0, 0)
editor.updateDrag(0.3, 0.3)
editor.endDrag()
const after = editor.getDocument().sections.find((s) => s.kind === "detail")!.children[1]!.frame
// 落点 5.7 → 6、10.7 → 11。
// 若改成对位移量取整,结果会是 5.4 和 10.4(位移 0.3 取整为 0),与期望不符。
expect(after.x).toBe(6)
expect(after.y).toBe(11)
})
})
describe("拖拽的事务语义", () => {
it("拖拽中途停顿超过时间窗,仍然只产生一步撤销", () => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"))
try {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
const frame = () =>
editor.getDocument().sections.find((s) => s.kind === "detail")!.children[0]!.frame
editor.select(id)
editor.beginDrag(0, 0)
editor.updateDrag(10, 10)
// 模拟用户拖到一半停手,闲置远超 History 的 500ms 合并窗口后继续拖。
// 若靠 mergeKey 时间窗合并,这里会被拆成两条撤销记录。
vi.setSystemTime(new Date("2026-01-01T00:00:10Z"))
editor.updateDrag(30, 30)
editor.endDrag()
expect(frame().x).toBe(30)
// 关键:一次撤销直接回到原位,而不是停在中间态 10
editor.undo()
expect(frame().x).toBe(0)
} finally {
vi.useRealTimers()
}
})
it("拖拽没有产生位移时不写入撤销记录", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const id = detail.children[0]!.id
editor.select(id)
editor.beginDrag(0, 0)
editor.endDrag()
expect(editor.canUndo()).toBe(false)
})
})
describe("吸附开关(snapToGrid)", () => {
it("开启时取整到整数 mm", () => {
const state = createDesignerState({ snapEnabled: true })
expect(snapToGrid(10.4, state)).toBe(10)
expect(snapToGrid(10.6, state)).toBe(11)
})
it("关闭时原样返回", () => {
const state = createDesignerState({ snapEnabled: false })
expect(snapToGrid(10.4, state)).toBe(10.4)
expect(snapToGrid(-3.7, state)).toBe(-3.7)
})
})
describe("多选切换与剪贴板", () => {
it("toggleSelect 在选中与取消之间切换", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const first = detail.children[0]!.id
const second = detail.children[1]!.id
editor.toggleSelect(first)
expect(editor.getSelectedIds()).toEqual([first])
// 再切一次同一个 → 取消选中
editor.toggleSelect(first)
expect(editor.getSelectedIds()).toHaveLength(0)
// 两个都切上 → 多选
editor.toggleSelect(first)
editor.toggleSelect(second)
expect(editor.getSelectedIds().sort()).toEqual([first, second].sort())
})
it("粘贴后选中新元素,而不是清空选中", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const sourceId = detail.children[0]!.id
const beforeCount = detail.children.length
editor.select(sourceId)
editor.copy()
editor.paste(detail.id)
const afterChildren = editor.getDocument().sections.find((s) => s.kind === "detail")!.children
expect(afterChildren.length).toBe(beforeCount + 1)
// 回归:旧实现这里会把选中清空
const selectedIds = editor.getSelectedIds()
expect(selectedIds).toHaveLength(1)
expect(selectedIds[0]).not.toBe(sourceId)
// 选中的确实是刚粘贴出来的那个
expect(afterChildren.some((c) => c.id === selectedIds[0])).toBe(true)
})
it("粘贴的元素相对源元素偏移 5mm 避免重叠", () => {
const doc = sampleDoc()
const editor = new EditorSession(doc)
const detail = editor.getDocument().sections.find((s) => s.kind === "detail")!
const source = detail.children[0]!
editor.select(source.id)
editor.copy()
editor.paste(detail.id)
const pasted = editor.getDocument().sections
.find((s) => s.kind === "detail")!
.children.find((c) => c.id === editor.getSelectedIds()[0])!
expect(pasted.frame.x).toBe(source.frame.x + 5)
expect(pasted.frame.y).toBe(source.frame.y + 5)
})
})