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,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)
})
})