231 lines
7.3 KiB
TypeScript
231 lines
7.3 KiB
TypeScript
/**
|
||
* 文本换行测试(对应设计文档 §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()
|
||
})
|
||
})
|