import { describe, expect, it, vi } from 'vitest'; import type { MeasureWidthImpl } from '../../measure/types'; import { searchBestLayoutApp } from '../index'; import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index'; function t(text: string, start: number) { return { text, start, end: start + text.length }; } describe('textWrap search-engine-app', () => { 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, lineMode: 'AUTO' as const, measure: { contextProfile: 'APP|ios|test', fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 }, measureWidthImpl: impl, }, scoring: { config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], }, lexicons: DEFAULT_LEXICONS, debug: true, }, }; const a = await searchBestLayoutApp(input as any); const b = await searchBestLayoutApp(input as any); const c = await searchBestLayoutApp(input as any); expect(a).toEqual(b); expect(b).toEqual(c); expect(impl).toHaveBeenCalled(); // 有测量发生 }); it('lineMode=FIXED:无解必须返回 NO_CANDIDATE', async () => { const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length); const res = await searchBestLayoutApp({ tokens: [t('I', 0), t('am', 2)], lang: 'EN', breakpoints: [{ pos: 1, kind: 'SPACE', priority: 10 }], availableWidth: 100, maxLines: 3, lineMode: 'FIXED', measure: { contextProfile: 'APP|ios|fixed', fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 }, measureWidthImpl: impl, }, scoring: { config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] }, lexicons: DEFAULT_LEXICONS, }, }); expect(res.ok).toBe(false); if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE'); }); it('TC H6:断点导致下一行行首为标点时必须禁止', async () => { const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length); const res = await searchBestLayoutApp({ tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)], lang: 'TC', // 若选择 pos=2,则第二行行首 token 为 ','(应禁止) breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }], availableWidth: 100, maxLines: 2, lineMode: 'FIXED', measure: { contextProfile: 'APP|ios|tc', fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 }, measureWidthImpl: impl, }, scoring: { config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] }, lexicons: DEFAULT_LEXICONS, }, }); // lineMode=FIXED 且唯一断点被禁止,因此无解 expect(res.ok).toBe(false); if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE'); }); it('TOO_LONG:超过阈值直接返回 TOO_LONG', async () => { const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length); const tokens = Array.from({ length: 31 }).map((_, i) => t('a', i)); const res = await searchBestLayoutApp({ tokens, lang: 'EN', breakpoints: [], availableWidth: 100, maxLines: 2, lineMode: 'AUTO', measure: { contextProfile: 'APP|ios|toolong', fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 }, measureWidthImpl: impl, }, scoring: { config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] }, lexicons: DEFAULT_LEXICONS, }, }); expect(res.ok).toBe(false); if (!res.ok) expect(res.reason).toBe('TOO_LONG'); }); });