初始化 ToolBox 开发者工具箱。
CI / docker (push) Successful in 11m42s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
renjue
2026-06-25 11:15:38 +08:00
commit f1502ed08c
66 changed files with 18020 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import { escapeHtml, highlightDiff } from '../../../src/utils/comparator/html.js'
describe('comparator/html', () => {
describe('escapeHtml', () => {
it('escapes special HTML characters', () => {
expect(escapeHtml('<script>"\'&</script>')).toBe(
'&lt;script&gt;&quot;&#39;&amp;&lt;/script&gt;'
)
})
it('handles empty string', () => {
expect(escapeHtml('')).toBe('')
})
it('coerces non-string values', () => {
expect(escapeHtml(123)).toBe('123')
})
})
describe('highlightDiff', () => {
it('returns escaped text when no ranges', () => {
expect(highlightDiff('hello', [])).toBe('hello')
expect(highlightDiff('a<b', null)).toBe('a&lt;b')
})
it('wraps diff ranges with highlight span', () => {
const result = highlightDiff('hello', [{ start: 1, end: 4 }])
expect(result).toBe('h<span class="diff-highlight">ell</span>o')
})
it('handles multiple ranges', () => {
const result = highlightDiff('abcdef', [
{ start: 0, end: 1 },
{ start: 4, end: 6 },
])
expect(result).toContain('<span class="diff-highlight">a</span>bcd')
expect(result).toContain('<span class="diff-highlight">ef</span>')
})
})
})
+243
View File
@@ -0,0 +1,243 @@
import { describe, it, expect } from 'vitest'
import {
NodeComparisonResult,
compareJsonNodes,
compareJson,
stringSimilarity,
calculateStats,
formatJsonData,
} from '../../../src/utils/comparator/jsonCompare.js'
describe('comparator/jsonCompare', () => {
describe('stringSimilarity', () => {
it('returns same for identical strings', () => {
expect(stringSimilarity('hello', 'hello')).toEqual({ type: 'same', similarity: 1.0 })
})
it('returns same for two empty strings', () => {
expect(stringSimilarity('', '')).toEqual({ type: 'same', similarity: 1.0 })
})
it('returns different for completely unrelated strings', () => {
expect(stringSimilarity('abc', 'xyz')).toEqual({ type: 'different', similarity: 0.0 })
})
it('returns similar for partially matching strings', () => {
const result = stringSimilarity('hello', 'hallo')
expect(result.type).toBe('similar')
expect(result.similarity).toBeGreaterThan(0)
expect(result.similarity).toBeLessThan(1)
})
})
describe('compareJsonNodes', () => {
it('compares identical primitives', () => {
expect(compareJsonNodes(1, 1).type).toBe(NodeComparisonResult.SAME)
expect(compareJsonNodes(true, true).type).toBe(NodeComparisonResult.SAME)
expect(compareJsonNodes(false, false).type).toBe(NodeComparisonResult.SAME)
})
it('compares different numbers as DIFFERENT', () => {
expect(compareJsonNodes(1, 2).type).toBe(NodeComparisonResult.DIFFERENT)
})
it('treats null === null as SAME', () => {
expect(compareJsonNodes(null, null).type).toBe(NodeComparisonResult.SAME)
})
it('treats null vs undefined as DIFFERENT', () => {
expect(compareJsonNodes(null, undefined).type).toBe(NodeComparisonResult.DIFFERENT)
})
it('treats null vs value as DIFFERENT', () => {
expect(compareJsonNodes(null, 1).type).toBe(NodeComparisonResult.DIFFERENT)
expect(compareJsonNodes(1, null).type).toBe(NodeComparisonResult.DIFFERENT)
})
it('compares strings with similarity', () => {
const result = compareJsonNodes('hello', 'hallo')
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
it('compares different types as DIFFERENT', () => {
expect(compareJsonNodes(1, '1').type).toBe(NodeComparisonResult.DIFFERENT)
expect(compareJsonNodes([], {}).type).toBe(NodeComparisonResult.DIFFERENT)
})
it('compares empty objects as SAME', () => {
expect(compareJsonNodes({}, {}).type).toBe(NodeComparisonResult.SAME)
})
it('compares empty arrays as SAME', () => {
expect(compareJsonNodes([], []).type).toBe(NodeComparisonResult.SAME)
})
it('detects added key as SIMILAR map', () => {
const result = compareJsonNodes({ a: 1 }, { a: 1, b: 2 })
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
expect(result.children.some(c => c.key === 'b')).toBe(true)
})
it('detects removed key', () => {
const result = compareJsonNodes({ a: 1, b: 2 }, { a: 1 })
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
it('detects changed value for same key as SIMILAR', () => {
const result = compareJsonNodes({ name: 'Alice' }, { name: 'Bob' })
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
const nameComp = result.children.find(c => c.key === 'name')
expect(nameComp.type).toBe(NodeComparisonResult.SIMILAR)
})
it('compares nested objects', () => {
const a = { user: { name: 'Alice', age: 30 } }
const b = { user: { name: 'Alice', age: 31 } }
const result = compareJsonNodes(a, b)
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
})
describe('compareJsonNodes list order', () => {
it('preserves order by default (compareLists)', () => {
const result = compareJsonNodes([1, 2, 3], [1, 3, 2], false)
expect(result.type).not.toBe(NodeComparisonResult.SAME)
})
it('ignores order when ignoreOrder=true', () => {
const result = compareJsonNodes([1, 2, 3], [3, 2, 1], true)
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
expect(result.similarity).toBeGreaterThan(0)
expect(result.matches.filter(m => m.indexA !== undefined && m.indexB !== undefined)).toHaveLength(3)
})
it('matches reorderable arrays with same elements', () => {
const result = compareJsonNodes(
[{ id: 1 }, { id: 2 }],
[{ id: 2 }, { id: 1 }],
true
)
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
expect(result.matches.filter(m => m.indexA !== undefined && m.indexB !== undefined)).toHaveLength(2)
})
it('detects element addition in ordered list', () => {
const result = compareJsonNodes([1, 2], [1, 2, 3], false)
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
it('handles empty vs non-empty array', () => {
const result = compareJsonNodes([], [1])
expect(result.type).toBe(NodeComparisonResult.SIMILAR)
})
})
describe('calculateStats', () => {
it('counts same leaf nodes', () => {
const comp = compareJsonNodes({ a: 1 }, { a: 1 })
const stats = calculateStats(comp)
expect(stats.same).toBeGreaterThan(0)
})
it('counts modify for changed string values', () => {
const comp = compareJsonNodes({ name: 'Alice' }, { name: 'Bob' })
const stats = calculateStats(comp)
expect(stats.modify).toBeGreaterThan(0)
})
it('counts insert/delete for added/removed keys', () => {
const comp = compareJsonNodes({ a: 1 }, { a: 1, b: 2 })
const stats = calculateStats(comp)
expect(stats.insert).toBeGreaterThan(0)
})
})
describe('formatJsonData', () => {
it('formats null', () => {
expect(formatJsonData(null)).toEqual(['null'])
})
it('formats string with quotes', () => {
expect(formatJsonData('hello')).toEqual(['"hello"'])
})
it('formats number and boolean', () => {
expect(formatJsonData(42)).toEqual(['42'])
expect(formatJsonData(true)).toEqual(['true'])
})
it('formats empty array and object', () => {
expect(formatJsonData([])).toEqual(['[]'])
expect(formatJsonData({})).toEqual(['{}'])
})
})
describe('compareJson (integration)', () => {
it('compares identical JSON text', () => {
const json = '{"a":1,"b":[1,2]}'
const result = compareJson(json, json)
expect(result.stats.same).toBeGreaterThan(0)
expect(result.left.length).toBe(result.right.length)
expect(result.left.every((l, i) => l.lineNumber === i + 1)).toBe(true)
})
it('throws on invalid JSON', () => {
expect(() => compareJson('{invalid', '{}')).toThrow('JSON解析失败')
expect(() => compareJson('{}', '[unclosed')).toThrow('JSON解析失败')
})
it('throws on empty invalid input', () => {
expect(() => compareJson('', '{}')).toThrow('JSON解析失败')
})
it('detects value change in JSON', () => {
const result = compareJson('{"name":"Alice"}', '{"name":"Bob"}')
expect(result.stats.modify).toBeGreaterThan(0)
})
it('respects ignoreListOrder option', () => {
const a = '{"items":[1,2,3]}'
const b = '{"items":[3,2,1]}'
const ordered = compareJson(a, b, false)
const ignored = compareJson(a, b, true)
expect(ignored.stats.same).toBeGreaterThanOrEqual(ordered.stats.same)
})
it('handles deeply nested JSON', () => {
const a = JSON.stringify({ level1: { level2: { level3: { value: 1 } } } })
const b = JSON.stringify({ level1: { level2: { level3: { value: 2 } } } })
const result = compareJson(a, b)
expect(result.stats.modify).toBeGreaterThan(0)
})
it('handles JSON with unicode', () => {
const a = '{"msg":"你好"}'
const b = '{"msg":"世界"}'
const result = compareJson(a, b)
expect(result.left.length).toBeGreaterThan(0)
})
it('handles boolean and null values', () => {
const a = '{"flag":true,"empty":null}'
const b = '{"flag":false,"empty":null}'
const result = compareJson(a, b)
expect(result.stats.modify).toBeGreaterThan(0)
})
it('handles array length mismatch', () => {
const result = compareJson('[1,2]', '[1,2,3]')
expect(result.stats.insert + result.stats.modify).toBeGreaterThan(0)
})
it('handles empty objects comparison', () => {
const result = compareJson('{}', '{}')
expect(result.left.length).toBe(result.right.length)
expect(result.left.every(l => l.type === 'same')).toBe(true)
})
it('handles whitespace in JSON parse (valid JSON)', () => {
const result = compareJson(' { "a" : 1 } ', '{ "a" : 1 }')
expect(result.stats.same).toBeGreaterThan(0)
})
})
})
+195
View File
@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest'
import {
computeDiff,
shouldMergeAsModify,
compareTextByLine,
compareTextByChar,
} from '../../../src/utils/comparator/textDiff.js'
describe('comparator/textDiff', () => {
describe('shouldMergeAsModify', () => {
it('returns false for identical lines', () => {
expect(shouldMergeAsModify('same', 'same')).toBe(false)
})
it('merges JSON key-value lines with same key', () => {
expect(shouldMergeAsModify(' "name": "Alice"', ' "name": "Bob"')).toBe(true)
})
it('does not merge JSON key-value lines with different keys', () => {
expect(shouldMergeAsModify(' "name": "Alice"', ' "age": 30')).toBe(false)
})
it('merges structurally similar lines (>50% prefix)', () => {
expect(shouldMergeAsModify(' { "a": 1 }', ' { "a": 2 }')).toBe(true)
})
it('does not merge completely different lines', () => {
expect(shouldMergeAsModify('foo', 'bar')).toBe(false)
})
})
describe('computeDiff', () => {
it('finds full match path', () => {
const path = computeDiff(['a', 'b'], ['a', 'b'])
expect(path.some(p => p.x === 0 && p.y === 0)).toBe(true)
expect(path.some(p => p.x === 1 && p.y === 1)).toBe(true)
})
it('prefers leftmost match for duplicate chars in B (hello vs helloworld)', () => {
const path = computeDiff('hello'.split(''), 'helloworld'.split(''))
const oMatch = path.find(p => {
const charA = 'hello'[p.x]
const charB = 'helloworld'[p.y]
return charA === 'o' && charB === 'o'
})
expect(oMatch).toBeDefined()
expect(oMatch.y).toBe(4)
})
it('handles empty arrays', () => {
const path = computeDiff([], [])
expect(path).toEqual([{ x: 0, y: 0 }])
})
it('handles one empty array', () => {
const path = computeDiff(['a'], [])
expect(path[path.length - 1]).toEqual({ x: 1, y: 0 })
})
})
describe('compareTextByLine', () => {
it('marks identical multi-line text as all same', () => {
const result = compareTextByLine('line1\nline2\nline3', 'line1\nline2\nline3')
expect(result.left.every(l => l.type === 'same')).toBe(true)
expect(result.right.every(l => l.type === 'same')).toBe(true)
expect(result.stats).toEqual({ same: 3, insert: 0, delete: 0, modify: 0 })
})
it('handles empty strings (single empty line)', () => {
const result = compareTextByLine('', '')
expect(result.stats.same).toBe(1)
})
it('detects pure insertion on right side', () => {
const result = compareTextByLine('a', 'a\nb')
expect(result.stats.insert).toBeGreaterThan(0)
const insertLine = result.right.find(l => l.type === 'insert')
expect(insertLine?.content).toBe('b')
})
it('detects pure deletion on right side', () => {
const result = compareTextByLine('a\nb', 'a')
expect(result.stats.delete).toBeGreaterThan(0)
const deleteLine = result.left.find(l => l.type === 'delete')
expect(deleteLine?.content).toBe('b')
})
it('merges similar JSON lines as modify', () => {
const result = compareTextByLine(
' "name": "Alice"',
' "name": "Bob"'
)
expect(result.stats.modify).toBe(1)
expect(result.left[0].type).toBe('modify')
expect(result.right[0].type).toBe('modify')
})
it('shows delete+insert for different JSON keys', () => {
const result = compareTextByLine(
' "name": "Alice"',
' "age": 30'
)
expect(result.stats.delete).toBe(1)
expect(result.stats.insert).toBe(1)
expect(result.stats.modify).toBe(0)
})
it('assigns line numbers correctly for same lines', () => {
const result = compareTextByLine('a\nb', 'a\nb')
expect(result.left[0].lineNumber).toBe(1)
expect(result.left[1].lineNumber).toBe(2)
})
it('sets null lineNumber for placeholder lines on opposite side', () => {
const result = compareTextByLine('only-left', 'only-right')
const leftInsert = result.left.find(l => l.type === 'insert')
const rightDelete = result.right.find(l => l.type === 'delete')
if (leftInsert) expect(leftInsert.lineNumber).toBeNull()
if (rightDelete) expect(rightDelete.lineNumber).toBeNull()
})
it('handles completely different single lines', () => {
const result = compareTextByLine('aaa', 'bbb')
expect(result.stats.delete + result.stats.insert + result.stats.modify).toBeGreaterThan(0)
})
it('handles trailing newline difference consistently', () => {
const result = compareTextByLine('a\n', 'a')
expect(result.left.length).toBe(result.right.length)
})
})
describe('compareTextByChar', () => {
it('marks identical lines as same with char count in stats', () => {
const result = compareTextByChar('hello', 'hello')
expect(result.left[0].type).toBe('same')
expect(result.stats.same).toBe(5)
})
it('highlights character differences inline', () => {
const result = compareTextByChar('abc', 'adc')
expect(result.left[0].inlineHighlight).toBe(true)
expect(result.left[0].html).toContain('diff-highlight')
})
it('handles empty vs non-empty line', () => {
const result = compareTextByChar('', 'hello')
expect(result.stats.insert).toBeGreaterThan(0)
})
it('handles insertion at end (hello vs helloworld)', () => {
const result = compareTextByChar('hello', 'helloworld')
expect(result.stats.insert).toBeGreaterThan(0)
expect(result.left[0].html).toContain('hello')
})
it('handles deletion (helloworld vs hello)', () => {
const result = compareTextByChar('helloworld', 'hello')
expect(result.stats.delete).toBeGreaterThan(0)
})
it('handles modify at start (na vs aa)', () => {
const result = compareTextByChar('na', 'aa')
expect(result.stats.delete).toBeGreaterThan(0)
expect(result.stats.insert).toBeGreaterThan(0)
expect(result.left[0].inlineHighlight).toBe(true)
})
it('aligns lines by index across multiline text', () => {
const result = compareTextByChar('a\nb', 'a\nc')
expect(result.left).toHaveLength(2)
expect(result.right).toHaveLength(2)
expect(result.left[0].type).toBe('same')
expect(result.left[1].type).toBe('modify')
})
it('handles extra lines on one side', () => {
const result = compareTextByChar('a', 'a\nb')
expect(result.left.length).toBe(2)
expect(result.left[1].content).toBe('')
expect(result.right[1].content).toBe('b')
})
it('escapes HTML in diff output', () => {
const result = compareTextByChar('<script>', '<script>')
expect(result.left[0].html).not.toContain('<script>')
expect(result.left[0].html).toContain('&lt;')
})
it('assigns sequential line numbers', () => {
const result = compareTextByChar('a\nb', 'a\nb')
expect(result.left.map(l => l.lineNumber)).toEqual([1, 2])
})
})
})