357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
/**
|
||
* 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)
|
||
})
|
||
})
|