Files

205 lines
8.8 KiB
TypeScript
Raw Permalink 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.
import { describe, expect, it } from 'vitest'
import { loadDocxBundle } from '../../../../src/main/services/ai/human/tools/docx/adapters/ooxml/nodeIndex'
import { buildDocxNetReview } from '../../../../src/main/services/ai/human/tools/docx/review/netReview'
import { graphemes } from '../../../../src/main/services/ai/human/tools/docx/review/inlineDiff'
import {
paragraphXml as p,
reviewBuffer,
reviewRuns,
storyText
} from '../../../harness/backend/docxReviewAssertions'
const compare = async (
before: string,
after: string,
oldExtras: Record<string, string | Buffer> = {},
newExtras = oldExtras
) => {
const source = await loadDocxBundle(await reviewBuffer(before, oldExtras))
const working = await loadDocxBundle(await reviewBuffer(after, newExtras))
const result = await buildDocxNetReview(source, working)
return { ...result, document: working.documentDom, zip: working.zip }
}
describe('DOCX first/final net review', () => {
it.each([
[
['title', 'date', 'conclusion', 'section', 'risk', 'tail'],
['title', 'date', 'risk', 'conclusion', 'section', 'tail']
],
[
['title', 'date', 'conclusion', 'section', 'risk', 'tail'],
['title', 'date', 'section', 'risk', 'conclusion', 'tail']
],
[
['A', 'B', 'C'],
['B', 'A', 'C']
],
[
['A', 'B', 'C', 'D'],
['C', 'D', 'A', 'B']
],
[
['A', 'same', 'B', 'same', 'C'],
['A', 'same', 'same', 'B', 'C']
],
[
['风险旧稿', '结论', '尾段'],
['结论', '尾段', '风险新稿']
],
[
['A', 'B'],
['B', 'new', 'A']
],
[
['A', 'A', 'B'],
['A', 'B', 'B']
]
])('restores both orders for %j → %j', async (before, after) => {
const result = await compare(before.map((t) => p(t)).join(''), after.map((t) => p(t)).join(''))
expect(storyText(result.document, false)).toEqual(before)
expect(storyText(result.document, true)).toEqual(after)
})
it.each([
['尾款126万元', '尾款128万元'],
['甲乙丙丁戊', '甲新丙好戊'],
['a b\tZ', 'a b\tQ'],
['👩🏽‍💻é🙂', '👩🏽‍💻é🙃'],
['abc', ''],
['', 'new'],
['123abc!?', '123ABC?'],
['重复重复', '重复变化重复']
])('character diff preserves %j and %j', async (before, after) => {
const result = await compare(p(before), p(after))
expect(storyText(result.document, false)).toEqual([before])
expect(storyText(result.document, true)).toEqual([after])
expect(result.stats).toEqual({ changeCount: 1, insertCount: 1, deleteCount: 1 })
expect(
reviewRuns(result.document)
.filter((r) => r.kind === 'deleted')
.every((r) => r.xml.includes('strike'))
).toBe(true)
})
it('splits by grapheme, including a cluster crossing run boundaries', async () => {
expect(graphemes('👩🏽‍💻é🇨🇳')).toEqual(['👩🏽‍💻', 'é', '🇨🇳'])
const result = await compare(
'<w:p><w:r><w:t>e</w:t></w:r><w:r><w:t>́旧</w:t></w:r></w:p>',
p('é新')
)
expect(
reviewRuns(result.document)
.filter((r) => r.kind === 'deleted')
.map((r) => r.text)
.join('')
).toBe('旧')
expect(storyText(result.document, true)).toEqual(['é新'])
})
it('ignores arbitrary run splits and node identities', async () => {
const result = await compare(
p('一致'),
'<w:p w:rsidR="ABCD"><w:r><w:t>一</w:t></w:r><w:r><w:t>致</w:t></w:r></w:p>'
)
expect(result.stats.changeCount).toBe(0)
expect(reviewRuns(result.document).every((r) => !r.kind)).toBe(true)
})
it('marks only characters whose format changes, preserving their foreground/background', async () => {
const result = await compare(
'<w:p><w:r><w:t>前</w:t></w:r><w:r><w:rPr><w:color w:val="223344"/><w:shd w:fill="FFFF00"/></w:rPr><w:t>中</w:t></w:r><w:r><w:t>后</w:t></w:r></w:p>',
'<w:p><w:r><w:t>前</w:t></w:r><w:r><w:rPr><w:color w:val="223344"/><w:shd w:fill="00FFFF"/><w:b/></w:rPr><w:t>中</w:t></w:r><w:r><w:t>后</w:t></w:r></w:p>'
)
const runs = reviewRuns(result.document)
expect(runs.filter((r) => r.kind).map((r) => r.text)).toEqual(['中'])
expect(runs[1]!.xml).toContain('223344')
expect(runs[1]!.xml).toContain('00FFFF')
expect(runs[1]!.xml).toContain('w:bdr')
expect(result.stats).toEqual({ changeCount: 1, insertCount: 0, deleteCount: 0 })
expect(storyText(result.document)).toEqual(['前中后'])
})
it('aligns repeated text with the retained formatting when simplification removes an earlier occurrence', async () => {
const before =
'<w:p><w:r><w:t>本产品是一套面向社区公开资料整理与分享场景的 </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>公开资料整理工作台</w:t></w:r><w:r><w:t>,支持资料检索。</w:t></w:r></w:p>'
const after =
'<w:p><w:r><w:t>本产品是一套</w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>公开资料整理工作台</w:t></w:r><w:r><w:t>,支持资料检索。</w:t></w:r></w:p>'
const result = await compare(before, after)
expect(storyText(result.document, false)).toEqual([
'本产品是一套面向社区公开资料整理与分享场景的 公开资料整理工作台,支持资料检索。'
])
expect(storyText(result.document, true)).toEqual([
'本产品是一套公开资料整理工作台,支持资料检索。'
])
expect(reviewRuns(result.document).filter((run) => run.kind === 'formatted')).toEqual([])
expect(
reviewRuns(result.document)
.filter((run) => run.kind === 'deleted')
.map((run) => run.text)
.join('')
).toBe('面向社区公开资料整理与分享场景的 ')
})
it('keeps real format changes when matching repeated formatting would remove unchanged text', async () => {
const result = await compare(
'<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>同</w:t></w:r><w:r><w:t>同</w:t></w:r></w:p>',
'<w:p><w:r><w:t>同</w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>同</w:t></w:r></w:p>'
)
const runs = reviewRuns(result.document)
expect(runs.every((run) => run.kind === 'formatted')).toBe(true)
expect(runs.map((run) => run.text).join('')).toBe('同同')
expect(result.stats.insertCount).toBe(0)
expect(result.stats.deleteCount).toBe(0)
expect(storyText(result.document, false)).toEqual(['同同'])
expect(storyText(result.document, true)).toEqual(['同同'])
})
it('uses border when background has been removed', async () => {
const result = await compare(p('背景', '<w:shd w:fill="FFFF00"/>'), p('背景'))
expect(
reviewRuns(result.document).every((r) => r.xml.includes('w:bdr') && !r.xml.includes('E9F7EF'))
).toBe(true)
})
it('does not split complex fields into visible characters', async () => {
const field = (result: string) =>
`<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>DATE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>${result}</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>`
const result = await compare(field('2025'), field('2026'))
expect(result.document.getElementsByTagName('w:instrText').length).toBe(2)
expect(storyText(result.document, false)).toEqual(['2025'])
expect(storyText(result.document, true)).toEqual(['2026'])
})
it('bounds output/time for a long, fragmented repeated paragraph', async () => {
const text = '重复测试'.repeat(3000)
const fragmented = `<w:p>${Array.from(text)
.map((char) => `<w:r><w:t>${char}</w:t></w:r>`)
.join('')}</w:p>`
const start = Date.now(),
result = await compare(fragmented, p(text.slice(0, -1) + '新'))
expect(Date.now() - start).toBeLessThan(15000)
expect(storyText(result.document, true)).toEqual([text.slice(0, -1) + '新'])
expect(reviewRuns(result.document).length).toBeLessThan(10)
}, 20000)
it('keeps unchanged characters when a large edit exceeds the fast alignment limit', async () => {
const before = '前缀' + '甲乙'.repeat(2000) + '后缀'
const after = '前缀' + '丙乙'.repeat(2000) + '后缀'
const start = Date.now(),
result = await compare(p(before), p(after))
const runs = reviewRuns(result.document)
expect(
runs
.filter((r) => r.kind === 'deleted')
.map((r) => r.text)
.join('')
).toBe('甲'.repeat(2000))
expect(
runs
.filter((r) => r.kind === 'inserted')
.map((r) => r.text)
.join('')
).toBe('丙'.repeat(2000))
expect(
runs
.filter((r) => !r.kind)
.map((r) => r.text)
.join('')
).toBe('前缀' + '乙'.repeat(2000) + '后缀')
expect(storyText(result.document, false)).toEqual([before])
expect(storyText(result.document, true)).toEqual([after])
expect(Date.now() - start).toBeLessThan(15000)
expect(runs.length).toBeLessThanOrEqual(before.length * 2)
}, 20000)
})