import { describe, expect, it, vi } from 'vitest'; import type { MeasureWidthImpl } from '../../measure/types'; import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index'; import { searchBestLayoutWidget } from '../index'; function t(text: string, start: number) { return { text, start, end: start + text.length }; } describe('textWrap search-engine-widget', () => { it('确定性:同输入多次调用 breaks/lines 必须一致', async () => { const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length); const input = { tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)], lang: 'EN' as const, breakpoints: [ { pos: 1, kind: 'SPACE', priority: 10 }, { pos: 2, kind: 'SPACE', priority: 10 }, { pos: 3, kind: 'SPACE', priority: 10 }, ], availableWidth: 6, maxLines: 2, context: 'WIDGET' as const, measure: { widthMode: 'MEASURE' as const, contextProfile: 'WIDGET|small|test', fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 }, measureWidthImpl: impl, widgetEnableMeasure: true, }, scoring: { config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], }, lexicons: DEFAULT_LEXICONS, debug: true, }, config: { beamK: 5, expandM: 12 }, }; const a = await searchBestLayoutWidget(input as any); const b = await searchBestLayoutWidget(input as any); const c = await searchBestLayoutWidget(input as any); expect(a).toEqual(b); expect(b).toEqual(c); }); it('widthMode=APPROX:仍可输出,并标记 meta.reason=WIDTH_UNKNOWN', async () => { const res = await searchBestLayoutWidget({ tokens: [t('我', 0), t('好', 1), t('累', 2)], lang: 'TC', breakpoints: [{ pos: 1, kind: 'BALANCE', priority: 5 }], // APPROX 模式下:availableWidth 与 width 单位都按“token 数”理解 availableWidth: 10, maxLines: 2, context: 'WIDGET', measure: { widthMode: 'APPROX' }, scoring: { config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] }, lexicons: DEFAULT_LEXICONS, debug: true, }, config: { beamK: 3, expandM: 2 }, }); expect(res.ok).toBe(true); if (res.ok) { expect(res.bestLayout.meta?.reason).toBe('WIDTH_UNKNOWN'); } }); it('性能约束:expandM/beamK 生效(通过测量调用次数粗略验证)', async () => { const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length); const res = await searchBestLayoutWidget({ tokens: [t('a', 0), t('b', 2), t('c', 4), t('d', 6), t('e', 8)], lang: 'EN', breakpoints: [ { pos: 1, kind: 'SPACE', priority: 10 }, { pos: 2, kind: 'SPACE', priority: 10 }, { pos: 3, kind: 'SPACE', priority: 10 }, { pos: 4, kind: 'SPACE', priority: 10 }, ], availableWidth: 100, maxLines: 3, context: 'WIDGET', measure: { widthMode: 'MEASURE', contextProfile: 'WIDGET|perf', fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 }, measureWidthImpl: impl, widgetEnableMeasure: true, }, scoring: { config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] }, lexicons: DEFAULT_LEXICONS, }, config: { beamK: 2, expandM: 2 }, }); expect(res.ok).toBe(true); // 每轮最多 beamK 个 beam,每个 beam 最多 expandM 次扩展 -> 粗略上限:maxLines*beamK*expandM expect((impl as any).mock.calls.length).toBeLessThanOrEqual(3 * 2 * 2 + 2); }); it('TC H6:行首标点导致的转移必须被禁止', async () => { const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length); const res = await searchBestLayoutWidget({ tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)], lang: 'TC', breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }], // 让“一行放下全文”不可能(触发超宽过滤),从而必须依赖断点切分 availableWidth: 3, maxLines: 2, context: 'WIDGET', measure: { widthMode: 'MEASURE', contextProfile: 'WIDGET|tc', fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 }, measureWidthImpl: impl, widgetEnableMeasure: true, }, scoring: { config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] }, lexicons: DEFAULT_LEXICONS, }, config: { beamK: 3, expandM: 3 }, }); expect(res.ok).toBe(false); if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE'); }); });