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