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

231 lines
7.3 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.
/**
* 文本换行测试(对应设计文档 §14 的"中文字体、长文本"测试)。
*
* 使用确定性估算度量器,保证断言与字体环境无关。
*/
import { describe, expect, it } from "vitest"
import { DEFAULT_RESOLVED_STYLE, type ResolvedStyle } from "@report/core"
import { createEstimateMeasurer } from "../src/measurer.js"
import {
wrapText,
segmentText,
isTextOverflow,
measureTextHeight,
isNoLineStart,
isNoLineEnd,
} from "../src/text-wrap.js"
const measurer = createEstimateMeasurer()
function style(overrides: Partial<ResolvedStyle> = {}): ResolvedStyle {
return { ...DEFAULT_RESOLVED_STYLE, fontSize: 10, ...overrides }
}
/** 10pt 字号下 1em ≈ 3.5278mm */
const EM = (10 / 72) * 25.4
describe("segmentText 分段", () => {
it("西文按词切分并保留空格", () => {
expect(segmentText("hello world")).toEqual(["hello ", "world"])
})
it("中文逐字切分", () => {
expect(segmentText("中文报表")).toEqual(["中", "文", "报", "表"])
})
it("中英混排正确切分", () => {
// 空格自身成段:它终结前一个词,同时供避头点逻辑判断
expect(segmentText("销售 abc 单")).toEqual(["销", "售", " ", "abc ", "单"])
})
it("空串返回空数组", () => {
expect(segmentText("")).toEqual([])
})
})
describe("wrapText 基本换行", () => {
it("短文本不换行", () => {
const result = wrapText("abc", { maxWidth: 100, style: style(), measurer })
expect(result.lines).toEqual(["abc"])
})
it("超宽英文在词边界换行", () => {
// "hello world" 约 5em;限制到 4em 才能触发词边界换行
const result = wrapText("hello world", {
maxWidth: 4 * EM,
style: style(),
measurer,
})
expect(result.lines.length).toBeGreaterThan(1)
// 不拆开单词:每行去掉空格后仍应是完整单词
for (const line of result.lines) {
expect(["hello", "world"]).toContain(line.trim())
}
})
it("中文在任意字之间换行", () => {
const result = wrapText("中文报表测试", { maxWidth: 3 * EM, style: style(), measurer })
expect(result.lines.length).toBe(2)
expect(result.lines[0]).toBe("中文报")
expect(result.lines[1]).toBe("表测试")
})
it("保留硬换行", () => {
const result = wrapText("a\nb", { maxWidth: 100, style: style(), measurer })
expect(result.lines).toEqual(["a", "b"])
})
it("空文本占一行", () => {
const result = wrapText("", { maxWidth: 100, style: style(), measurer })
expect(result.lines).toEqual([""])
expect(result.totalHeight).toBeCloseTo(EM * 1.2, 4)
})
it("每行宽度不超过限制(中文)", () => {
const result = wrapText("中文报表测试内容", { maxWidth: 2 * EM, style: style(), measurer })
for (const width of result.widths) {
expect(width).toBeLessThanOrEqual(2 * EM + 1e-6)
}
})
it("长文本换行后行数合理", () => {
const text = "中".repeat(100)
const result = wrapText(text, { maxWidth: 10 * EM, style: style(), measurer })
expect(result.lines.length).toBe(10)
})
it("总高度 = 行数 × 行高", () => {
const result = wrapText("中".repeat(10), { maxWidth: 5 * EM, style: style(), measurer })
expect(result.totalHeight).toBeCloseTo(result.lineHeight * result.lines.length, 4)
})
it("行高随字号变化", () => {
const a = wrapText("x", { maxWidth: 100, style: style({ fontSize: 10 }), measurer })
const b = wrapText("x", { maxWidth: 100, style: style({ fontSize: 20 }), measurer })
expect(b.lineHeight).toBeCloseTo(a.lineHeight * 2, 4)
})
})
describe("wrapText 强制断词", () => {
it("超长单词被强制断开", () => {
const result = wrapText("abcdefghijklmnop", { maxWidth: 5 * EM, style: style(), measurer })
expect(result.brokeWords).toBe(true)
expect(result.lines.length).toBeGreaterThan(1)
})
it("禁用断词时整词放一行(允许溢出)", () => {
const result = wrapText("abcdefghijklmnop", {
maxWidth: 5 * EM,
style: style(),
measurer,
breakLongWords: false,
})
expect(result.brokeWords).toBe(false)
expect(result.lines).toEqual(["abcdefghijklmnop"])
})
it("超长中文串不触发 brokeWords(逐字即可换行)", () => {
const result = wrapText("中".repeat(20), { maxWidth: 3 * EM, style: style(), measurer })
expect(result.brokeWords).toBe(false)
})
})
describe("避头点规则", () => {
it("识别不可行首的标点", () => {
expect(isNoLineStart(",")).toBe(true)
expect(isNoLineStart("。")).toBe(true)
expect(isNoLineStart(")")).toBe(true)
expect(isNoLineStart("中")).toBe(false)
})
it("识别不可行尾的标点", () => {
expect(isNoLineEnd("(")).toBe(true)
expect(isNoLineEnd("中")).toBe(false)
})
it("空串两种规则都返回 false", () => {
expect(isNoLineStart("")).toBe(false)
expect(isNoLineEnd("")).toBe(false)
})
it("行尾不出现左括号", () => {
// 宽度刚好让 "(" 落在行尾时,应把它推到下一行
const result = wrapText("中文(测试)", { maxWidth: 3 * EM, style: style(), measurer })
for (const line of result.lines) {
if (line === "") continue
expect(isNoLineEnd(line)).toBe(false)
}
})
it("行首不出现逗号", () => {
const result = wrapText("中文,测试", { maxWidth: 3 * EM, style: style(), measurer })
for (const line of result.lines) {
if (line === "") continue
expect(isNoLineStart(line)).toBe(false)
}
})
})
describe("measureTextHeight", () => {
it("返回高度与行数", () => {
const result = measureTextHeight("中".repeat(10), {
maxWidth: 5 * EM,
style: style(),
measurer,
})
expect(result.lineCount).toBe(2)
expect(result.height).toBeGreaterThan(0)
})
it("指定最大高度时能识别溢出", () => {
const result = measureTextHeight(
"中".repeat(10),
{ maxWidth: 5 * EM, style: style(), measurer },
EM, // 只允许一行
)
expect(result.overflow).toBe(true)
})
it("未指定最大高度时 overflow 恒为 false", () => {
const result = measureTextHeight("中".repeat(100), {
maxWidth: 5 * EM,
style: style(),
measurer,
})
expect(result.overflow).toBe(false)
})
})
describe("isTextOverflow", () => {
it("内容超出高度返回 true", () => {
expect(isTextOverflow("中".repeat(10), 5 * EM, EM, style(), measurer)).toBe(true)
})
it("内容未超出返回 false", () => {
expect(isTextOverflow("中", 5 * EM, 10 * EM, style(), measurer)).toBe(false)
})
})
describe("边界情况", () => {
it("零宽度时每字一行", () => {
const result = wrapText("中文字", { maxWidth: 0, style: style(), measurer })
expect(result.lines.length).toBe(3)
})
it("纯空白文本", () => {
const result = wrapText(" ", { maxWidth: 100, style: style(), measurer })
expect(result.lines.length).toBeGreaterThanOrEqual(1)
})
it("连续硬换行保留空行", () => {
const result = wrapText("a\n\nb", { maxWidth: 100, style: style(), measurer })
expect(result.lines).toEqual(["a", "", "b"])
})
it("标签或特殊字符不崩溃", () => {
expect(() =>
wrapText("<b>粗体</b> & \"引号\"", { maxWidth: 3 * EM, style: style(), measurer }),
).not.toThrow()
})
})