Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getByteLength, truncateToMaxBytes } from '../../src/utils/byteUtils.js'
|
||||
|
||||
describe('byteUtils', () => {
|
||||
it('counts ASCII bytes', () => {
|
||||
expect(getByteLength('hello')).toBe(5)
|
||||
expect(getByteLength('')).toBe(0)
|
||||
})
|
||||
|
||||
it('counts UTF-8 multibyte characters', () => {
|
||||
expect(getByteLength('中')).toBe(3)
|
||||
expect(getByteLength('hello世界')).toBe(11)
|
||||
})
|
||||
|
||||
it('returns original string when within limit', () => {
|
||||
expect(truncateToMaxBytes('hello', 10)).toBe('hello')
|
||||
})
|
||||
|
||||
it('truncates by bytes without breaking multibyte chars', () => {
|
||||
const text = 'a'.repeat(10) + '中'
|
||||
const truncated = truncateToMaxBytes(text, 11)
|
||||
expect(getByteLength(truncated)).toBeLessThanOrEqual(11)
|
||||
expect(truncated).not.toContain('中')
|
||||
})
|
||||
|
||||
it('handles exact byte boundary', () => {
|
||||
expect(truncateToMaxBytes('ab', 2)).toBe('ab')
|
||||
expect(truncateToMaxBytes('abc', 2)).toBe('ab')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { rgbToHex, rgbToHsl, hexToRgb, hslToRgb } from '../../src/utils/color.js'
|
||||
|
||||
describe('color conversion', () => {
|
||||
it('converts RGB to hex', () => {
|
||||
expect(rgbToHex(255, 0, 0)).toBe('FF0000')
|
||||
expect(rgbToHex(0, 255, 0)).toBe('00FF00')
|
||||
expect(rgbToHex(0, 0, 255)).toBe('0000FF')
|
||||
})
|
||||
|
||||
it('pads single-digit hex values', () => {
|
||||
expect(rgbToHex(1, 2, 3)).toBe('010203')
|
||||
})
|
||||
|
||||
it('clamps RGB values to 0-255', () => {
|
||||
expect(rgbToHex(300, -10, 128)).toBe('FF0080')
|
||||
})
|
||||
|
||||
it('converts hex to RGB', () => {
|
||||
expect(hexToRgb('FF0000')).toEqual({ r: 255, g: 0, b: 0 })
|
||||
expect(hexToRgb('invalid')).toBeNull()
|
||||
})
|
||||
|
||||
it('converts RGB to HSL and back (gray)', () => {
|
||||
const hsl = rgbToHsl(128, 128, 128)
|
||||
expect(hsl.s).toBe(0)
|
||||
const rgb = hslToRgb(hsl.h, hsl.s, hsl.l)
|
||||
expect(rgb.r).toBeCloseTo(128, 0)
|
||||
expect(rgb.g).toBeCloseTo(128, 0)
|
||||
expect(rgb.b).toBeCloseTo(128, 0)
|
||||
})
|
||||
|
||||
it('converts pure red RGB <-> HSL', () => {
|
||||
const hsl = rgbToHsl(255, 0, 0)
|
||||
expect(hsl.h).toBe(0)
|
||||
expect(hsl.s).toBe(100)
|
||||
const rgb = hslToRgb(0, 100, 50)
|
||||
expect(rgb.r).toBe(255)
|
||||
expect(rgb.g).toBe(0)
|
||||
expect(rgb.b).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
'<script>"'&</script>'
|
||||
)
|
||||
})
|
||||
|
||||
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<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>')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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('<')
|
||||
})
|
||||
|
||||
it('assigns sequential line numbers', () => {
|
||||
const result = compareTextByChar('a\nb', 'a\nb')
|
||||
expect(result.left.map(l => l.lineNumber)).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
encodeBase64,
|
||||
decodeBase64,
|
||||
encodeUrl,
|
||||
decodeUrl,
|
||||
encodeUnicode,
|
||||
decodeUnicode,
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
} from '../../src/utils/encoder.js'
|
||||
import { zlibSync, decompressSync } from 'fflate'
|
||||
|
||||
describe('encoder', () => {
|
||||
describe('base64', () => {
|
||||
it('encodes and decodes ASCII text', () => {
|
||||
expect(decodeBase64(encodeBase64('hello'))).toBe('hello')
|
||||
})
|
||||
|
||||
it('encodes and decodes unicode text', () => {
|
||||
const text = '你好世界'
|
||||
expect(decodeBase64(encodeBase64(text))).toBe(text)
|
||||
})
|
||||
})
|
||||
|
||||
describe('url', () => {
|
||||
it('encodes and decodes URL components', () => {
|
||||
expect(decodeUrl(encodeUrl('a b&c=1'))).toBe('a b&c=1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unicode escape', () => {
|
||||
it('encodes BMP characters', () => {
|
||||
expect(encodeUnicode('A')).toBe('\\u0041')
|
||||
})
|
||||
|
||||
it('roundtrips BMP text', () => {
|
||||
expect(decodeUnicode('\\u0048\\u0065\\u006C\\u006C\\u006F')).toBe('Hello')
|
||||
})
|
||||
|
||||
it('encodes and decodes emoji (surrogate pair)', () => {
|
||||
const emoji = '😀'
|
||||
const encoded = encodeUnicode(emoji)
|
||||
expect(decodeUnicode(encoded)).toBe(emoji)
|
||||
})
|
||||
|
||||
it('decodes \\UXXXXXXXX format', () => {
|
||||
expect(decodeUnicode('\\U0001F600')).toBe('😀')
|
||||
})
|
||||
|
||||
it('throws on invalid unicode code point', () => {
|
||||
expect(() => decodeUnicode('\\U00110000')).toThrow('Unicode 解码失败')
|
||||
})
|
||||
})
|
||||
|
||||
describe('binary helpers', () => {
|
||||
it('roundtrips bytes through base64', () => {
|
||||
const bytes = new Uint8Array([1, 2, 3, 255])
|
||||
expect([...base64ToBytes(bytesToBase64(bytes))]).toEqual([...bytes])
|
||||
})
|
||||
|
||||
it('strips whitespace when decoding base64', () => {
|
||||
const bytes = new Uint8Array([72, 101, 108, 108, 111])
|
||||
expect(new TextDecoder().decode(base64ToBytes('SGVs\nbG8='))).toBe('Hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('zlib roundtrip', () => {
|
||||
it('compresses and decompresses text', () => {
|
||||
const text = 'hello zlib compression test'
|
||||
const compressed = zlibSync(new TextEncoder().encode(text))
|
||||
const b64 = bytesToBase64(compressed)
|
||||
const decompressed = new TextDecoder().decode(decompressSync(base64ToBytes(b64)))
|
||||
expect(decompressed).toBe(text)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
parseJsonPath,
|
||||
pathToJsonPath,
|
||||
pathMatchesJsonPath,
|
||||
getDataByPath,
|
||||
} from '../../src/utils/jsonFormatter/jsonPath.js'
|
||||
|
||||
describe('jsonPath', () => {
|
||||
describe('parseJsonPath', () => {
|
||||
it('returns null for empty path', () => {
|
||||
expect(parseJsonPath('')).toBeNull()
|
||||
expect(parseJsonPath(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty array for root $', () => {
|
||||
expect(parseJsonPath('$')).toEqual([])
|
||||
expect(parseJsonPath('$.')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses object keys', () => {
|
||||
expect(parseJsonPath('$.name')).toEqual([{ type: 'key', key: 'name' }])
|
||||
expect(parseJsonPath('$.user.name')).toEqual([
|
||||
{ type: 'key', key: 'user' },
|
||||
{ type: 'key', key: 'name' },
|
||||
])
|
||||
})
|
||||
|
||||
it('parses array index', () => {
|
||||
expect(parseJsonPath('$.items[0]')).toEqual([
|
||||
{ type: 'key', key: 'items' },
|
||||
{ type: 'index', index: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('parses wildcard index', () => {
|
||||
expect(parseJsonPath('$.items[*]')).toEqual([
|
||||
{ type: 'key', key: 'items' },
|
||||
{ type: 'wildcard', index: '*' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pathToJsonPath', () => {
|
||||
it('converts internal path to JSONPath', () => {
|
||||
expect(pathToJsonPath('root')).toBe('$')
|
||||
expect(pathToJsonPath('root.user.name')).toBe('$.user.name')
|
||||
})
|
||||
})
|
||||
|
||||
describe('pathMatchesJsonPath', () => {
|
||||
it('matches exact key path', () => {
|
||||
const segments = parseJsonPath('$.user.name')
|
||||
expect(pathMatchesJsonPath('root.user.name', segments)).toBe(true)
|
||||
expect(pathMatchesJsonPath('root.user.age', segments)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires exact segment count', () => {
|
||||
const segments = parseJsonPath('$.user')
|
||||
expect(pathMatchesJsonPath('root.user.name', segments)).toBe(false)
|
||||
})
|
||||
|
||||
it('matches wildcard index', () => {
|
||||
const segments = parseJsonPath('$.items[*]')
|
||||
expect(pathMatchesJsonPath('root.items[2]', segments)).toBe(true)
|
||||
expect(pathMatchesJsonPath('root.items.name', segments)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for empty segments (match all)', () => {
|
||||
expect(pathMatchesJsonPath('root.any.path', [])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDataByPath', () => {
|
||||
const data = { user: { name: 'Alice', tags: ['a', 'b'] }, count: 3 }
|
||||
|
||||
it('gets nested object value', () => {
|
||||
expect(getDataByPath(data, 'root.user.name')).toBe('Alice')
|
||||
})
|
||||
|
||||
it('gets array element', () => {
|
||||
expect(getDataByPath(data, 'root.user.tags[1]')).toBe('b')
|
||||
})
|
||||
|
||||
it('returns undefined for missing path', () => {
|
||||
expect(getDataByPath(data, 'root.missing')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import router from '../../src/router/index.js'
|
||||
|
||||
describe('router', () => {
|
||||
it('registers all toolbox routes', () => {
|
||||
const paths = router.getRoutes().map(r => r.path)
|
||||
expect(paths).toContain('/')
|
||||
expect(paths).toContain('/json-formatter')
|
||||
expect(paths).toContain('/comparator')
|
||||
expect(paths).toContain('/encoder-decoder')
|
||||
expect(paths).toContain('/variable-name')
|
||||
expect(paths).toContain('/qr-code')
|
||||
expect(paths).toContain('/timestamp-converter')
|
||||
expect(paths).toContain('/color-converter')
|
||||
})
|
||||
|
||||
it('sets page title suffix via meta', () => {
|
||||
const comparator = router.getRoutes().find(r => r.name === 'Comparator')
|
||||
expect(comparator.meta.titleSuffix).toBe('对比')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import {
|
||||
getSiteTitle,
|
||||
getSiteIcp,
|
||||
getPageTitle,
|
||||
getGaMeasurementId,
|
||||
getJinrishiciSdkUrl,
|
||||
} from '../../src/config/site.js'
|
||||
import { initAnalytics } from '../../src/utils/analytics.js'
|
||||
|
||||
describe('site config', () => {
|
||||
beforeEach(() => {
|
||||
delete window.__SITE_CONFIG__
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__SITE_CONFIG__
|
||||
})
|
||||
|
||||
it('uses default title and icp', () => {
|
||||
expect(getSiteTitle()).toBe('ToolBox')
|
||||
expect(getSiteIcp()).toBe('')
|
||||
})
|
||||
|
||||
it('builds page title with suffix', () => {
|
||||
expect(getPageTitle('JSON')).toBe('ToolBox-JSON')
|
||||
expect(getPageTitle(null)).toBe('ToolBox')
|
||||
})
|
||||
|
||||
it('prefers runtime config over defaults', () => {
|
||||
window.__SITE_CONFIG__ = { title: '自定义工具箱', icp: '京ICP备12345678号' }
|
||||
expect(getSiteTitle()).toBe('自定义工具箱')
|
||||
expect(getSiteIcp()).toBe('京ICP备12345678号')
|
||||
expect(getPageTitle('对比')).toBe('自定义工具箱-对比')
|
||||
})
|
||||
|
||||
it('allows empty icp to hide footer', () => {
|
||||
window.__SITE_CONFIG__ = { title: 'ToolBox', icp: '' }
|
||||
expect(getSiteIcp()).toBe('')
|
||||
})
|
||||
|
||||
it('disables third-party integrations by default', () => {
|
||||
expect(getGaMeasurementId()).toBe('')
|
||||
expect(getJinrishiciSdkUrl()).toBe('')
|
||||
})
|
||||
|
||||
it('loads analytics only when configured', () => {
|
||||
window.__SITE_CONFIG__ = { gaId: 'G-TEST123' }
|
||||
initAnalytics()
|
||||
expect(document.querySelector('script[src*="googletagmanager.com"]')).not.toBeNull()
|
||||
expect(typeof window.gtag).toBe('function')
|
||||
})
|
||||
|
||||
it('respects runtime privacy overrides', () => {
|
||||
window.__SITE_CONFIG__ = {
|
||||
gaId: 'G-RUNTIME',
|
||||
jinrishiciSdkUrl: 'https://example.com/sdk.js',
|
||||
}
|
||||
expect(getGaMeasurementId()).toBe('G-RUNTIME')
|
||||
expect(getJinrishiciSdkUrl()).toBe('https://example.com/sdk.js')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
parseTimestampInput,
|
||||
formatTimestampMs,
|
||||
parseDateString,
|
||||
dateToTimestamp,
|
||||
} from '../../src/utils/timestamp.js'
|
||||
|
||||
describe('timestamp', () => {
|
||||
// 2024-01-15 12:30:45 UTC+8 depends on local TZ - use fixed ms
|
||||
const knownMs = 1705299045123
|
||||
|
||||
describe('parseTimestampInput', () => {
|
||||
it('returns empty error for blank input', () => {
|
||||
expect(parseTimestampInput('', 'seconds')).toEqual({ error: 'empty' })
|
||||
})
|
||||
|
||||
it('parses seconds timestamp (10 digits)', () => {
|
||||
const seconds = Math.floor(knownMs / 1000)
|
||||
const result = parseTimestampInput(String(seconds), 'seconds')
|
||||
expect(result.timestampMs).toBe(seconds * 1000)
|
||||
})
|
||||
|
||||
it('parses milliseconds timestamp', () => {
|
||||
const result = parseTimestampInput(String(knownMs), 'milliseconds')
|
||||
expect(result.timestampMs).toBe(knownMs)
|
||||
})
|
||||
|
||||
it('parses 13-digit value as milliseconds even in seconds mode', () => {
|
||||
const result = parseTimestampInput(String(knownMs), 'seconds')
|
||||
expect(result.timestampMs).toBe(knownMs)
|
||||
})
|
||||
|
||||
it('parses nanoseconds timestamp', () => {
|
||||
const ns = BigInt(knownMs) * BigInt(1000000) + BigInt(456789)
|
||||
const result = parseTimestampInput(String(ns), 'nanoseconds')
|
||||
expect(result.timestampMs).toBe(knownMs)
|
||||
expect(result.nanoseconds).toBe(456789)
|
||||
})
|
||||
|
||||
it('returns error for invalid number', () => {
|
||||
expect(parseTimestampInput('abc', 'seconds')).toEqual({ error: 'invalid_number' })
|
||||
})
|
||||
|
||||
it('returns error for invalid nanoseconds', () => {
|
||||
expect(parseTimestampInput('not-a-bigint', 'nanoseconds')).toEqual({ error: 'invalid_nanoseconds' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTimestampMs', () => {
|
||||
it('formats seconds precision without milliseconds', () => {
|
||||
const result = formatTimestampMs(knownMs, 'seconds')
|
||||
expect(result.value).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)
|
||||
expect(result.value).not.toContain('.')
|
||||
})
|
||||
|
||||
it('formats milliseconds precision', () => {
|
||||
const result = formatTimestampMs(knownMs, 'milliseconds')
|
||||
expect(result.value).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}$/)
|
||||
})
|
||||
|
||||
it('formats nanoseconds precision with extra digits', () => {
|
||||
const result = formatTimestampMs(knownMs, 'nanoseconds', 123456)
|
||||
expect(result.value).toMatch(/\.\d{9}$/)
|
||||
})
|
||||
|
||||
it('returns error for invalid date', () => {
|
||||
expect(formatTimestampMs(NaN, 'seconds')).toEqual({ error: 'invalid_date' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDateString', () => {
|
||||
it('returns empty error for blank', () => {
|
||||
expect(parseDateString('')).toEqual({ error: 'empty' })
|
||||
})
|
||||
|
||||
it('parses standard datetime format', () => {
|
||||
const result = parseDateString('2024-06-15 10:30:00')
|
||||
expect(result.date).toBeInstanceOf(Date)
|
||||
expect(result.date.getFullYear()).toBe(2024)
|
||||
})
|
||||
|
||||
it('parses datetime with milliseconds', () => {
|
||||
const result = parseDateString('2024-06-15 10:30:00.123')
|
||||
expect(result.date).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it('returns error for invalid date string', () => {
|
||||
expect(parseDateString('not-a-date')).toEqual({ error: 'invalid_date' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('dateToTimestamp', () => {
|
||||
const date = new Date(2024, 5, 15, 10, 30, 0)
|
||||
|
||||
it('converts to seconds', () => {
|
||||
const result = dateToTimestamp(date, 'seconds')
|
||||
expect(parseInt(result.value, 10)).toBe(Math.floor(date.getTime() / 1000))
|
||||
})
|
||||
|
||||
it('converts to milliseconds', () => {
|
||||
const result = dateToTimestamp(date, 'milliseconds')
|
||||
expect(result.value).toBe(String(date.getTime()))
|
||||
})
|
||||
|
||||
it('converts to nanoseconds', () => {
|
||||
const result = dateToTimestamp(date, 'nanoseconds')
|
||||
expect(result.value).toBe(String(BigInt(date.getTime()) * BigInt(1000000)))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { countLines } from '../../src/composables/useLineNumberEditor.js'
|
||||
|
||||
describe('useLineNumberEditor.countLines', () => {
|
||||
it('returns 1 for empty or whitespace-only text', () => {
|
||||
expect(countLines('')).toBe(1)
|
||||
expect(countLines(null)).toBe(1)
|
||||
expect(countLines(undefined)).toBe(1)
|
||||
})
|
||||
|
||||
it('counts single line without trailing newline', () => {
|
||||
expect(countLines('hello')).toBe(1)
|
||||
})
|
||||
|
||||
it('counts multiple lines', () => {
|
||||
expect(countLines('a\nb\nc')).toBe(3)
|
||||
expect(countLines('a\nb\n')).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
parseToWords,
|
||||
toCamelCase,
|
||||
toPascalCase,
|
||||
toSnakeCase,
|
||||
toKebabCase,
|
||||
toConstantCase,
|
||||
} from '../../src/utils/variableName.js'
|
||||
|
||||
describe('variableName', () => {
|
||||
describe('parseToWords', () => {
|
||||
it('returns empty array for blank input', () => {
|
||||
expect(parseToWords('')).toEqual([])
|
||||
expect(parseToWords(' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('parses snake_case', () => {
|
||||
expect(parseToWords('hello_world')).toEqual(['hello', 'world'])
|
||||
})
|
||||
|
||||
it('parses kebab-case', () => {
|
||||
expect(parseToWords('hello-world')).toEqual(['hello', 'world'])
|
||||
})
|
||||
|
||||
it('parses camelCase', () => {
|
||||
expect(parseToWords('helloWorld')).toEqual(['hello', 'world'])
|
||||
})
|
||||
|
||||
it('parses PascalCase', () => {
|
||||
expect(parseToWords('HelloWorld')).toEqual(['hello', 'world'])
|
||||
})
|
||||
|
||||
it('parses consecutive uppercase (XMLHttpRequest)', () => {
|
||||
expect(parseToWords('XMLHttpRequest')).toEqual(['xml', 'http', 'request'])
|
||||
})
|
||||
|
||||
it('parses numbers in identifiers', () => {
|
||||
expect(parseToWords('item2')).toEqual(['item', '2'])
|
||||
expect(parseToWords('temp2Detail')).toEqual(['temp', '2', 'detail'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('case conversions', () => {
|
||||
const words = ['hello', 'world']
|
||||
|
||||
it('converts to camelCase', () => {
|
||||
expect(toCamelCase(words)).toBe('helloWorld')
|
||||
expect(toCamelCase([])).toBe('')
|
||||
})
|
||||
|
||||
it('converts to PascalCase', () => {
|
||||
expect(toPascalCase(words)).toBe('HelloWorld')
|
||||
})
|
||||
|
||||
it('converts to snake_case', () => {
|
||||
expect(toSnakeCase(words)).toBe('hello_world')
|
||||
})
|
||||
|
||||
it('converts to kebab-case', () => {
|
||||
expect(toKebabCase(words)).toBe('hello-world')
|
||||
})
|
||||
|
||||
it('converts to CONSTANT_CASE', () => {
|
||||
expect(toConstantCase(words)).toBe('HELLO_WORLD')
|
||||
})
|
||||
|
||||
it('preserves numeric words in camelCase', () => {
|
||||
expect(toCamelCase(['item', '2'])).toBe('item2')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user