20260920173439
This commit is contained in:
1 parent
f4bfc1ddfc
commit
6f03d92425
174 files changed
+23490
-157
No files matched your search
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 端到端链路冒烟测试(§17 V1 验收标准)。
|
||||
*
|
||||
* 验证完整链路:
|
||||
* ReportDocument(core)
|
||||
* → 数据注入 + 布局(layout,含分组/聚合)
|
||||
* → LayoutDocument
|
||||
* → HTML/SVG Renderer(renderer)
|
||||
* → Designer 编辑(designer)
|
||||
*/
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { createDocument, createTableElement } from "@report/core"
|
||||
import { createEstimateMeasurer, createPassthroughFontResolver, layoutDocument } from "@report/layout"
|
||||
import { renderHtml, renderSvg, createPdfRenderer } from "@report/renderer"
|
||||
import { EditorSession, createTextElement } from "@report/designer"
|
||||
|
||||
const measurer = createEstimateMeasurer()
|
||||
const fontResolver = createPassthroughFontResolver()
|
||||
|
||||
describe("端到端冒烟(§17)", () => {
|
||||
it("完整链路:建文档 → 布局 → HTML/SVG → Designer 编辑", async () => {
|
||||
// 1. Core:创建文档 + 明细表
|
||||
const doc = createDocument({ name: "销售单" })
|
||||
doc.datasets = { sales: { id: "sales" } }
|
||||
const detail = doc.sections.find((s) => s.kind === "detail")!
|
||||
detail.children.push(
|
||||
createTableElement("tbl", { x: 0, y: 0, width: 180, height: 20 }, "sales", [
|
||||
{ id: "c1", width: 90, header: "商品", binding: { field: "name" } },
|
||||
{ id: "c2", width: 90, header: "金额", binding: { field: "amount" }, align: "right" },
|
||||
]),
|
||||
)
|
||||
const header = doc.sections.find((s) => s.kind === "page-header")!
|
||||
header.children.push({
|
||||
id: "title",
|
||||
type: "text",
|
||||
frame: { x: 0, y: 0, width: 100, height: 8 },
|
||||
props: { text: "销售单" },
|
||||
})
|
||||
|
||||
const rows = [
|
||||
{ name: "苹果", amount: 100 },
|
||||
{ name: "香蕉", amount: 200 },
|
||||
{ name: "橙子", amount: 300 },
|
||||
]
|
||||
|
||||
// 2. Layout:数据注入 + 布局
|
||||
const layout = await layoutDocument(
|
||||
doc,
|
||||
{ data: { sales: rows }, parameters: { } },
|
||||
{ textMeasurer: measurer, fontResolver },
|
||||
)
|
||||
expect(layout.pageCount).toBeGreaterThanOrEqual(1)
|
||||
expect(layout.diagnostics.filter((d) => d.severity === "error")).toHaveLength(0)
|
||||
|
||||
// 3. Renderer:HTML + SVG 渲染,页数一致
|
||||
const html = renderHtml(layout)
|
||||
const svg = renderSvg(layout)
|
||||
expect(html.pageCount).toBe(layout.pageCount)
|
||||
expect(svg.pageCount).toBe(layout.pageCount)
|
||||
expect(html.content).toContain("销售单")
|
||||
expect(html.content).toContain("苹果")
|
||||
expect(svg.content).toContain("苹果")
|
||||
|
||||
// PDF 缺后端 → 诊断而非抛异常
|
||||
const pdf = createPdfRenderer().render(layout)
|
||||
expect(pdf.target).toBe("pdf")
|
||||
expect(pdf.diagnostics.length).toBeGreaterThan(0)
|
||||
|
||||
// 4. Designer:编辑(移动 + 撤销)
|
||||
const editor = new EditorSession(doc)
|
||||
editor.select("title")
|
||||
const before = editor.getDocument().sections
|
||||
.find((s) => s.kind === "page-header")!.children[0]!.frame
|
||||
editor.moveElements({ title: { x: 20, y: 0, width: 100, height: 8 } })
|
||||
const afterMove = editor.getDocument().sections
|
||||
.find((s) => s.kind === "page-header")!.children[0]!.frame
|
||||
expect(afterMove.x).toBe(20)
|
||||
editor.undo()
|
||||
const afterUndo = editor.getDocument().sections
|
||||
.find((s) => s.kind === "page-header")!.children[0]!.frame
|
||||
expect(afterUndo.x).toBe(before.x)
|
||||
|
||||
// 新增元素
|
||||
const beforeCount = editor.getDocument().sections
|
||||
.find((s) => s.kind === "detail")!.children.length
|
||||
editor.addElement(detail.id, createTextElement({ x: 0, y: 0, width: 30, height: 8 }, "备注"))
|
||||
const afterAdd = editor.getDocument().sections
|
||||
.find((s) => s.kind === "detail")!.children.length
|
||||
expect(afterAdd).toBe(beforeCount + 1)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user