diff --git a/tests/frontend/unit/b2/figureLayout.test.js b/tests/frontend/unit/b2/figureLayout.test.js new file mode 100644 index 0000000..16b6f09 --- /dev/null +++ b/tests/frontend/unit/b2/figureLayout.test.js @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +// figureLayout.test.js —— 聊天图片布局纯函数(2026-09-01) +// 需求:单图 50% 宽;连续两张比例相同的图并排一行各 50%,留间距。 +import { describe, it, expect } from 'vitest'; +import { aspectOf, sameAspect, pairFigures, isInlineAdjacent, layoutTextRoot } from '../../../../ui/src/components/chat/figureLayout.js'; + +const fig = () => document.createElement('figure'); + +describe('aspectOf / sameAspect', () => { + it('未加载(0 值)返回 null', () => { + expect(aspectOf(0, 0)).toBe(null); + expect(aspectOf(0, 100)).toBe(null); + expect(aspectOf(100, 0)).toBe(null); + }); + it('宽高比计算', () => { + expect(aspectOf(100, 50)).toBe(2); + expect(aspectOf(800, 400)).toBe(2); + }); + it('容差 2% 内判同比例', () => { + expect(sameAspect(1, 1)).toBe(true); + expect(sameAspect(1.0, 1.01)).toBe(true); + expect(sameAspect(1.0, 1.03)).toBe(false); + expect(sameAspect(1, null)).toBe(false); + expect(sameAspect(null, null)).toBe(false); + expect(sameAspect(4 / 3, 800 / 600)).toBe(true); + }); +}); + +describe('pairFigures 贪心配对', () => { + const figs = [fig(), fig(), fig(), fig()]; + it('相邻同比例 → 配对;不同 → 落单', () => { + const g = pairFigures([figs[0], figs[1]], [1.5, 1.5]); + expect(g).toHaveLength(1); + expect(g[0].row).toBe(true); + expect(g[0].figures).toEqual([figs[0], figs[1]]); + const g2 = pairFigures([figs[0], figs[1]], [1.5, 1.0]); + expect(g2).toHaveLength(2); + expect(g2.every((x) => !x.row)).toBe(true); + }); + it('3 张连续同比例 = 1 对 + 1 单', () => { + const g = pairFigures([figs[0], figs[1], figs[2]], [1, 1, 1]); + expect(g).toHaveLength(2); + expect(g[0].row).toBe(true); + expect(g[1].row).toBe(false); + }); + it('空列表', () => { + expect(pairFigures([], [])).toEqual([]); + }); + it('比例未知(null)不配对', () => { + const g = pairFigures([figs[0], figs[1]], [null, null]); + expect(g).toHaveLength(2); + expect(g.every((x) => !x.row)).toBe(true); + }); +}); + +describe('isInlineAdjacent 连续性', () => { + it('同一
内仅空白相隔 → 相邻', () => { + const p = document.createElement('p'); + const a = fig(); + const b = fig(); + p.append(a, document.createTextNode(' '), b); + expect(isInlineAdjacent(a, b)).toBe(true); + }); + it('相邻兄弟
→ 相邻(markdown-it 逐图成段)', () => { + const wrap = document.createElement('div'); + const pa = document.createElement('p'); + const pb = document.createElement('p'); + pa.append(fig()); + pb.append(fig()); + wrap.append(pa, pb); + expect(isInlineAdjacent(pa.firstChild, pb.firstChild)).toBe(true); + }); + it('中间有文字段落 → 不相邻', () => { + const wrap = document.createElement('div'); + const pa = document.createElement('p'); + const pm = document.createElement('p'); + const pb = document.createElement('p'); + pa.append(fig()); + pm.textContent = '说明文字'; + pb.append(fig()); + wrap.append(pa, pm, pb); + expect(isInlineAdjacent(pa.firstChild, pb.firstChild)).toBe(false); + }); +}); + +describe('layoutTextRoot 幂等布局', () => { + function makeRoot(html) { + const root = document.createElement('div'); + root.className = 'md-text'; + root.innerHTML = html; + return root; + } + function readyImg(w, h) { + const img = document.createElement('img'); + Object.defineProperty(img, 'naturalWidth', { value: w }); + Object.defineProperty(img, 'naturalHeight', { value: h }); + return img; + } + it('单图 → half 类,无 row', () => { + const root = makeRoot('
分隔文字
后留下的空
)视为空白 */ +const isEmptyBlock = (node) => + node.nodeType === 1 && !node.querySelector('*') && node.textContent.trim() === ''; + +/** x、y 之间的兄弟节点是否只有空白文本/空块元素(无实体内容) */ +export function betweenWhitespaceOnly(x, y) { + let node = x.nextSibling; + while (node && node !== y) { + if (node.nodeType === 3) { + if (node.textContent.trim() !== '') return false; + } else if (node.nodeType === 1) { + if (!isEmptyBlock(node)) return false; + } else { + return false; + } + node = node.nextSibling; + } + return node === y; +} + +/** + * 两张 figure 是否「连续出现」: + * - 同一
内(行内图)仅空白相隔;或 + * - 各自所在
是相邻兄弟且仅空白相隔(markdown-it 逐图成段的标准形态)。 + */ +export function isInlineAdjacent(a, b) { + const ca = a.closest('p') || a; + const cb = b.closest('p') || b; + if (ca === cb) return betweenWhitespaceOnly(a, b); + if (!ca.parentElement || ca.parentElement !== cb.parentElement) return false; + return betweenWhitespaceOnly(ca, cb); +} + +/** + * 从左到右贪心配对:相邻且同比例 → { figures:[a,b], row:true }; + * 落单 → { figures:[f], row:false }。3 张连续同比例 = 1 对 + 1 单。 + * @param {HTMLElement[]} figures figure.md-figure 元素(DOM 顺序) + * @param {(number|null)[]} aspects 与 figures 等长的宽高比数组 + */ +export function pairFigures(figures, aspects) { + const groups = []; + let i = 0; + while (i < figures.length) { + if (i + 1 < figures.length && sameAspect(aspects[i], aspects[i + 1])) { + groups.push({ figures: [figures[i], figures[i + 1]], row: true }); + i += 2; + } else { + groups.push({ figures: [figures[i]], row: false }); + i += 1; + } + } + return groups; +} + +/** + * 对一个 .md-text 根做布局(幂等,可反复调用): + * 1. 拆掉旧的 .figure-row 包裹、清 half 类; + * 2. 图片未加载完成(naturalWidth=0)→ 返回 pending img 列表,等 load 后重排; + * 3. 按「连续出现」切 run,run 内相邻同比例 → 包 .figure-row;落单 → figure 加 .half。 + * @returns {{done: boolean, pending: HTMLImageElement[]}} + */ +export function layoutTextRoot(root) { + // 加载失败的图片由组件层隐藏(figure.style.display='none'),不参与布局 + const figures = Array.from(root.querySelectorAll('.md-figure')).filter( + (f) => !(f instanceof HTMLElement && f.style.display === 'none') + ); + + // 幂等:拆旧包裹 + for (const row of root.querySelectorAll('.figure-row')) { + const parent = row.parentElement; + while (row.firstChild) parent.insertBefore(row.firstChild, row); + parent.removeChild(row); + } + figures.forEach((f) => f.classList.remove('half')); + + const aspects = figures.map((f) => { + const img = f.querySelector('img'); + return img ? aspectOf(img.naturalWidth || 0, img.naturalHeight || 0) : null; + }); + + const pending = []; + figures.forEach((f, i) => { + if (aspects[i] == null) { + const img = f.querySelector('img'); + if (img) pending.push(img); + } + }); + if (pending.length) return { done: false, pending }; + + // 按连续性切 run + const runs = []; + let run = []; + for (const f of figures) { + if (run.length && !isInlineAdjacent(run[run.length - 1], f)) { + runs.push(run); + run = []; + } + run.push(f); + } + if (run.length) runs.push(run); + + for (const r of runs) { + const groups = pairFigures( + r, + r.map((f) => { + const img = f.querySelector('img'); + return aspectOf(img.naturalWidth, img.naturalHeight); + }) + ); + for (const g of groups) { + if (g.row) { + const row = document.createElement('div'); + row.className = 'figure-row'; + const parent = g.figures[0].parentElement; + parent.insertBefore(row, g.figures[0]); + row.appendChild(g.figures[0]); + row.appendChild(g.figures[1]); + } else { + g.figures[0].classList.add('half'); + } + } + } + return { done: true, pending: [] }; +}