diff --git a/packages/json-joy/src/index.ts b/packages/json-joy/src/index.ts index 15b61078f9..3fa92a8370 100644 --- a/packages/json-joy/src/index.ts +++ b/packages/json-joy/src/index.ts @@ -12,4 +12,3 @@ export type * from './json-crdt'; export type * from './json-crdt-patch'; export type * from './json-crdt-extensions'; export type * from './json-patch/types'; -export type * from '../../json-path/src/types'; diff --git a/packages/json-joy/src/json-patch-diff/JsonPatchDiff.ts b/packages/json-joy/src/json-patch-diff/JsonPatchDiff.ts index 1d4d818a58..29badd174c 100644 --- a/packages/json-joy/src/json-patch-diff/JsonPatchDiff.ts +++ b/packages/json-joy/src/json-patch-diff/JsonPatchDiff.ts @@ -2,6 +2,7 @@ import {deepEqual} from '@jsonjoy.com/util/lib/json-equal/deepEqual'; import * as str from '../util/diff/str'; import * as line from '../util/diff/line'; import {structHash} from '../json-hash'; +import {escapeComponent} from '@jsonjoy.com/json-pointer/lib/util'; import type {Operation} from '../json-patch/codec/json/types'; export class JsonPatchDiff { @@ -34,14 +35,14 @@ export class JsonPatchDiff { const val1 = src[key]; const val2 = dst[key]; if (val1 === val2) continue; - this.diffAny(path + '/' + key, val1, val2); + this.diffAny(path + '/' + escapeComponent(key), val1, val2); } else { - patch.push({op: 'remove', path: path + '/' + key}); + patch.push({op: 'remove', path: path + '/' + escapeComponent(key)}); } } for (const key in dst) { if (key in src) continue; - patch.push({op: 'add', path: path + '/' + key, value: dst[key]}); + patch.push({op: 'add', path: path + '/' + escapeComponent(key), value: dst[key]}); } } @@ -78,6 +79,16 @@ export class JsonPatchDiff { } public diffAny(path: string, src: unknown, dst: unknown): void { + // Check for type changes first + const srcType = Array.isArray(src) ? 'array' : typeof src; + const dstType = Array.isArray(dst) ? 'array' : typeof dst; + + if (srcType !== dstType) { + // Different types, replace entirely + this.diffVal(path, src, dst); + return; + } + switch (typeof src) { case 'string': { if (typeof dst === 'string') this.diffStr(path, src, dst); diff --git a/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-edge-cases.spec.ts b/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-edge-cases.spec.ts new file mode 100644 index 0000000000..67d6592cc6 --- /dev/null +++ b/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-edge-cases.spec.ts @@ -0,0 +1,366 @@ +import {assertDiff} from './util'; +import {RandomJson} from '@jsonjoy.com/json-random'; +import {JsonPatchDiff} from '../JsonPatchDiff'; + +describe('Edge Cases', () => { + describe('null and undefined handling', () => { + test('null to value', () => { + assertDiff(null, 42); + assertDiff(null, 'hello'); + assertDiff(null, []); + assertDiff(null, {}); + }); + + test('value to null', () => { + assertDiff(42, null); + assertDiff('hello', null); + assertDiff([], null); + assertDiff({}, null); + }); + + test('null in objects', () => { + assertDiff({a: null}, {a: 'value'}); + assertDiff({a: 'value'}, {a: null}); + assertDiff({a: null, b: 1}, {a: null, b: 2}); + }); + + test('null in arrays', () => { + assertDiff([null], ['value']); + assertDiff(['value'], [null]); + assertDiff([null, 1], [null, 2]); + }); + }); + + describe('empty structures', () => { + test('empty to empty', () => { + assertDiff({}, {}); + assertDiff([], []); + assertDiff('', ''); + }); + + test('empty to non-empty', () => { + assertDiff({}, {a: 1}); + assertDiff([], [1]); + assertDiff('', 'hello'); + }); + + test('non-empty to empty', () => { + assertDiff({a: 1}, {}); + assertDiff([1], []); + assertDiff('hello', ''); + }); + + test('nested empty structures', () => { + assertDiff({a: {}}, {a: {b: 1}}); + assertDiff({a: []}, {a: [1]}); + assertDiff([{}], [{a: 1}]); + assertDiff([[]], [[1]]); + }); + }); + + describe('type conversions', () => { + test('string to number', () => { + assertDiff('123', 123); + assertDiff('0', 0); + assertDiff('3.14', 3.14); + }); + + test('number to string', () => { + assertDiff(123, '123'); + assertDiff(0, '0'); + assertDiff(3.14, '3.14'); + }); + + test('boolean conversions', () => { + assertDiff(true, false); + assertDiff(true, 1); + assertDiff(false, 0); + assertDiff(true, 'true'); + assertDiff(false, 'false'); + }); + + test('array to object', () => { + assertDiff([1, 2, 3], {0: 1, 1: 2, 2: 3}); + assertDiff([], {}); + }); + + test('object to array', () => { + assertDiff({0: 1, 1: 2, 2: 3}, [1, 2, 3]); + assertDiff({}, []); + }); + + test('primitive to object/array', () => { + assertDiff(42, {value: 42}); + assertDiff('hello', [1, 2, 3]); + assertDiff(true, {boolean: true}); + }); + }); + + describe('special string characters', () => { + test('unicode characters', () => { + assertDiff('hello', 'héllo'); + assertDiff('🚀', '🎉'); + assertDiff('普通话', '中文'); + }); + + test('emoji sequences', () => { + assertDiff('👨‍👩‍👧‍👦', '👨‍👩‍👧'); + assertDiff('🏳️‍🌈', '🏳️‍⚧️'); + }); + + test('control characters', () => { + assertDiff('hello\nworld', 'hello\tworld'); + assertDiff('line1\r\nline2', 'line1\nline2'); + }); + + test('special characters', () => { + assertDiff('', '\u0000'); + assertDiff('hello\u0000world', 'hello world'); + assertDiff('quotes"test', "quotes'test"); + }); + + test('surrogate pairs', () => { + assertDiff('𝒽𝑒𝓁𝓁𝑜', '𝓦𝑜𝓇𝓁𝒹'); + assertDiff('🌟⭐✨', '🔥💥🎆'); + }); + + test('very long strings', () => { + const longStr1 = 'a'.repeat(1000); + const longStr2 = 'b'.repeat(1000); + assertDiff(longStr1, longStr2); + + const longStr3 = 'hello'.repeat(200); + const longStr4 = 'world'.repeat(200); + assertDiff(longStr3, longStr4); + }); + }); + + describe('special object keys', () => { + test('empty string key', () => { + assertDiff({'': 'value'}, {'': 'changed'}); + assertDiff({}, {'': 'value'}); + assertDiff({'': 'value'}, {}); + }); + + test('basic special characters in keys', () => { + assertDiff({'key with spaces': 1}, {'key with spaces': 2}); + assertDiff({key_with_underscores: 1}, {key_with_underscores: 2}); + assertDiff({'key.with.dots': 1}, {'key.with.dots': 2}); + }); + + test('numeric string keys', () => { + assertDiff({'0': 'zero'}, {'0': 'changed'}); + assertDiff({'123': 'number'}, {'123': 'string'}); + }); + + test('prototype pollution attempts (safe handling)', () => { + // These should be handled safely, not cause errors + // Note: The diff algorithm correctly identifies and applies these changes + // but the result may not be what you'd expect due to prototype behavior + const src1 = {}; + const dst1 = {__proto__: {polluted: true}}; + + // We'll test that the diff can be generated and applied without errors + const diff1 = new JsonPatchDiff(); + const patch1 = diff1.diff('/test', src1, dst1); + expect(patch1.length).toBeGreaterThan(0); + + const src2 = {constructor: 'safe'}; + const dst2 = {constructor: 'changed'}; + assertDiff(src2, dst2); + }); + }); + + describe('deep nesting', () => { + test('deeply nested objects', () => { + const deep1: any = {}; + const deep2: any = {}; + let curr1 = deep1; + let curr2 = deep2; + + for (let i = 0; i < 10; i++) { + curr1.nested = {value: i}; + curr2.nested = {value: i + 1}; + curr1 = curr1.nested; + curr2 = curr2.nested; + } + + assertDiff(deep1, deep2); + }); + + test('deeply nested arrays', () => { + const deep1: any = []; + const deep2: any = []; + let curr1 = deep1; + let curr2 = deep2; + + for (let i = 0; i < 10; i++) { + const next1 = [i]; + const next2 = [i + 1]; + curr1.push(next1); + curr2.push(next2); + curr1 = next1; + curr2 = next2; + } + + assertDiff(deep1, deep2); + }); + + test('mixed deep nesting', () => { + const deep1 = { + level1: { + arr: [ + { + level3: { + arr2: [1, 2, {level4: 'deep'}], + }, + }, + ], + }, + }; + + const deep2 = { + level1: { + arr: [ + { + level3: { + arr2: [1, 2, {level4: 'changed'}], + }, + }, + ], + }, + }; + + assertDiff(deep1, deep2); + }); + }); + + describe('large structures', () => { + test('large arrays', () => { + const large1 = Array.from({length: 1000}, (_, i) => i); + const large2 = Array.from({length: 1000}, (_, i) => i * 2); + assertDiff(large1, large2); + }); + + test('large objects', () => { + const large1: Record = {}; + const large2: Record = {}; + + for (let i = 0; i < 500; i++) { + large1[`key${i}`] = i; + large2[`key${i}`] = i * 2; + } + + assertDiff(large1, large2); + }); + + test('many small changes in large structure', () => { + const base = RandomJson.generate({nodeCount: 50}); + const modified = JSON.parse(JSON.stringify(base)); + + // Make small modifications throughout + if (typeof modified === 'object' && modified !== null) { + if (Array.isArray(modified)) { + for (let i = 0; i < Math.min(5, modified.length); i++) { + if (typeof modified[i] === 'number') { + modified[i] = modified[i] + 1; + } + } + } else { + const keys = Object.keys(modified); + for (let i = 0; i < Math.min(5, keys.length); i++) { + const key = keys[i]; + if (typeof modified[key] === 'number') { + modified[key] = modified[key] + 1; + } + } + } + } + + assertDiff(base, modified); + }); + }); + + describe('boundary conditions', () => { + test('single character changes', () => { + assertDiff('a', 'b'); + assertDiff('x', ''); + assertDiff('', 'y'); + }); + + test('arrays with single elements', () => { + assertDiff([0], [1]); + assertDiff([null], [undefined]); + assertDiff([{}], [[]]); + }); + + test('objects with single properties', () => { + assertDiff({a: 1}, {a: 2}); + assertDiff({a: 1}, {b: 1}); + assertDiff({x: null}, {x: undefined}); + }); + + test('mixed type arrays', () => { + assertDiff([1, 'two', true, null, {}, []], [2, 'three', false, undefined, [], {}]); + assertDiff(['a', 1, true], [1, 'a', false]); + }); + + test('sparse arrays', () => { + const sparse1: any[] = []; + sparse1[0] = 'first'; + sparse1[5] = 'sixth'; + + const sparse2: any[] = []; + sparse2[0] = 'changed'; + sparse2[5] = 'sixth'; + sparse2[10] = 'eleventh'; + + assertDiff(sparse1, sparse2); + }); + }); + + describe('bigint support', () => { + test('bigint values', () => { + assertDiff(BigInt(123), BigInt(456)); + assertDiff(BigInt(0), BigInt(1)); + assertDiff(BigInt(Number.MAX_SAFE_INTEGER), BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)); + }); + + test('bigint to other types', () => { + assertDiff(BigInt(123), 123); + assertDiff(BigInt(123), '123'); + assertDiff(123, BigInt(123)); + assertDiff('123', BigInt(123)); + }); + + test('bigint in structures', () => { + assertDiff({value: BigInt(123)}, {value: BigInt(456)}); + assertDiff([BigInt(1), BigInt(2)], [BigInt(2), BigInt(3)]); + }); + }); + + describe('identical documents', () => { + test('should produce empty patch for identical primitives', () => { + const primitives = [null, undefined, true, false, 0, 1, -1, 3.14, '', 'hello', BigInt(123)]; + for (const primitive of primitives) { + assertDiff(primitive, primitive); + } + }); + + test('should produce empty patch for identical objects', () => { + const obj = {a: 1, b: 'test', c: [1, 2, 3], d: {nested: true}}; + assertDiff(obj, obj); + }); + + test('should produce empty patch for identical arrays', () => { + const arr = [1, 'test', {a: 1}, [1, 2]]; + assertDiff(arr, arr); + }); + + test('should produce empty patch for structurally identical but different objects', () => { + const obj1 = {a: 1, b: 'test'}; + const obj2 = {a: 1, b: 'test'}; + assertDiff(obj1, obj2); + }); + }); +}); diff --git a/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-fuzzing.spec.ts b/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-fuzzing.spec.ts index 43d4709de3..9f266e34e2 100644 --- a/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-fuzzing.spec.ts +++ b/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-fuzzing.spec.ts @@ -1,4 +1,5 @@ import {assertDiff, randomArray} from './util'; +import {RandomJson} from '@jsonjoy.com/json-random'; const iterations = 100; @@ -15,3 +16,96 @@ test('two random arrays of integers', () => { } } }); + +test('random JSON documents', () => { + for (let i = 0; i < iterations; i++) { + const src = RandomJson.generate(); + const dst = RandomJson.generate(); + try { + assertDiff(src, dst); + } catch (error) { + console.error('src', JSON.stringify(src, null, 2)); + console.error('dst', JSON.stringify(dst, null, 2)); + throw error; + } + } +}); + +test('random JSON documents with small node count', () => { + for (let i = 0; i < iterations; i++) { + const src = RandomJson.generate({nodeCount: 5}); + const dst = RandomJson.generate({nodeCount: 5}); + try { + assertDiff(src, dst); + } catch (error) { + console.error('src', JSON.stringify(src, null, 2)); + console.error('dst', JSON.stringify(dst, null, 2)); + throw error; + } + } +}); + +test('random JSON documents with array root', () => { + for (let i = 0; i < iterations; i++) { + const src = RandomJson.generate({rootNode: 'array'}); + const dst = RandomJson.generate({rootNode: 'array'}); + try { + assertDiff(src, dst); + } catch (error) { + console.error('src', JSON.stringify(src, null, 2)); + console.error('dst', JSON.stringify(dst, null, 2)); + throw error; + } + } +}); + +test('random JSON documents with object root', () => { + for (let i = 0; i < iterations; i++) { + const src = RandomJson.generate({rootNode: 'object'}); + const dst = RandomJson.generate({rootNode: 'object'}); + try { + assertDiff(src, dst); + } catch (error) { + console.error('src', JSON.stringify(src, null, 2)); + console.error('dst', JSON.stringify(dst, null, 2)); + throw error; + } + } +}); + +test('random string generation', () => { + for (let i = 0; i < iterations; i++) { + const src = RandomJson.genString(Math.floor(Math.random() * 50)); + const dst = RandomJson.genString(Math.floor(Math.random() * 50)); + try { + assertDiff(src, dst); + } catch (error) { + console.error('src', JSON.stringify(src)); + console.error('dst', JSON.stringify(dst)); + throw error; + } + } +}); + +test('modify same document', () => { + for (let i = 0; i < iterations; i++) { + const src = RandomJson.generate({nodeCount: 8}); + // Create a slightly modified version + const dst = JSON.parse(JSON.stringify(src)); + + // Add random modification + if (typeof dst === 'object' && dst !== null && !Array.isArray(dst)) { + dst.randomProp = Math.random(); + } else if (Array.isArray(dst) && dst.length > 0) { + dst[Math.floor(Math.random() * dst.length)] = Math.random(); + } + + try { + assertDiff(src, dst); + } catch (error) { + console.error('src', JSON.stringify(src, null, 2)); + console.error('dst', JSON.stringify(dst, null, 2)); + throw error; + } + } +}); diff --git a/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-performance.spec.ts b/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-performance.spec.ts new file mode 100644 index 0000000000..f12d04a01e --- /dev/null +++ b/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-performance.spec.ts @@ -0,0 +1,316 @@ +import {assertDiff, randomMixedArray, randomObject, randomNestedStructure, createSimilarDocument} from './util'; +import {RandomJson} from '@jsonjoy.com/json-random'; +import {JsonPatchDiff} from '../JsonPatchDiff'; + +describe('Performance and Stress Tests', () => { + describe('large document handling', () => { + test('very large arrays', () => { + const large1 = Array.from({length: 2000}, (_, i) => ({ + id: i, + name: `item-${i}`, + value: Math.random(), + active: i % 2 === 0, + })); + + const large2 = large1.map((item, i) => ({ + ...item, + value: item.value + 0.1, + active: i % 3 === 0, + })); + + assertDiff(large1, large2); + }); + + test('very large objects', () => { + const large1: Record = {}; + const large2: Record = {}; + + for (let i = 0; i < 1000; i++) { + large1[`property_${i}`] = { + index: i, + data: `value-${i}`, + nested: { + deep: i * 2, + flag: i % 2 === 0, + }, + }; + + large2[`property_${i}`] = { + index: i, + data: `updated-${i}`, + nested: { + deep: i * 3, + flag: i % 3 === 0, + }, + }; + } + + assertDiff(large1, large2); + }); + + test('deeply nested structures', () => { + const deep1: any = {start: true}; + const deep2: any = {start: true}; + let current1 = deep1; + let current2 = deep2; + + for (let i = 0; i < 50; i++) { + current1.level = { + index: i, + data: `level-${i}`, + items: Array.from({length: 3}, (_, j) => `item-${i}-${j}`), + }; + current2.level = { + index: i, + data: `updated-level-${i}`, + items: Array.from({length: 3}, (_, j) => `updated-item-${i}-${j}`), + }; + current1 = current1.level; + current2 = current2.level; + } + + assertDiff(deep1, deep2); + }); + + test('wide object with many properties', () => { + const wide1: Record = {}; + const wide2: Record = {}; + + // Create objects with many properties + for (let i = 0; i < 500; i++) { + wide1[`key${i}`] = i; + wide2[`key${i}`] = i % 100 === 0 ? i + 1 : i; // Change every 100th value + } + + assertDiff(wide1, wide2); + }); + }); + + describe('complex structural changes', () => { + test('complete type transformation', () => { + const original = { + users: [ + {id: 1, name: 'Alice'}, + {id: 2, name: 'Bob'}, + ], + settings: { + theme: 'dark', + notifications: true, + }, + }; + + const transformed = ['User: Alice (ID: 1)', 'User: Bob (ID: 2)', 'Theme: dark', 'Notifications: enabled']; + + assertDiff(original, transformed); + }); + + test('array to object transformation', () => { + const arr = ['first', 'second', 'third', 'fourth']; + const obj = { + 0: 'first', + 1: 'second-modified', + 2: 'third', + 3: 'fourth', + 4: 'fifth', + }; + + assertDiff(arr, obj); + }); + + test('object restructuring', () => { + const original = { + user: { + personal: { + firstName: 'John', + lastName: 'Doe', + }, + contact: { + email: 'john@example.com', + phone: '123-456-7890', + }, + }, + }; + + const restructured = { + fullName: 'John Doe', + contacts: [ + {type: 'email', value: 'john@example.com'}, + {type: 'phone', value: '123-456-7890'}, + ], + }; + + assertDiff(original, restructured); + }); + }); + + describe('stress testing with random data', () => { + const iterations = 10; // Reduced for stability + + test('random complex documents', () => { + for (let i = 0; i < iterations; i++) { + const doc1 = RandomJson.generate({nodeCount: 15}); // Reduced complexity + const doc2 = RandomJson.generate({nodeCount: 15}); + + try { + assertDiff(doc1, doc2); + } catch (error) { + console.error('doc1', JSON.stringify(doc1, null, 2)); + console.error('doc2', JSON.stringify(doc2, null, 2)); + throw error; + } + } + }); + + test('similar documents with variations', () => { + for (let i = 0; i < iterations; i++) { + const original = RandomJson.generate({nodeCount: 12}); // Reduced complexity + const modified = createSimilarDocument(original, 0.1); // Lower mutation rate + + try { + assertDiff(original, modified); + } catch (error) { + console.error('original', JSON.stringify(original, null, 2)); + console.error('modified', JSON.stringify(modified, null, 2)); + throw error; + } + } + }); + + test('mixed type arrays stress test', () => { + for (let i = 0; i < iterations; i++) { + const arr1 = randomMixedArray(10); // Reduced size + const arr2 = randomMixedArray(10); + + try { + assertDiff(arr1, arr2); + } catch (error) { + console.error('arr1', JSON.stringify(arr1, null, 2)); + console.error('arr2', JSON.stringify(arr2, null, 2)); + throw error; + } + } + }); + + test('complex object stress test', () => { + for (let i = 0; i < iterations; i++) { + const obj1 = randomObject(8); // Reduced size + const obj2 = randomObject(8); + + try { + assertDiff(obj1, obj2); + } catch (error) { + console.error('obj1', JSON.stringify(obj1, null, 2)); + console.error('obj2', JSON.stringify(obj2, null, 2)); + throw error; + } + } + }); + + test('nested structure stress test', () => { + for (let i = 0; i < iterations; i++) { + const struct1 = randomNestedStructure(3); // Reduced depth + const struct2 = randomNestedStructure(3); + + try { + assertDiff(struct1, struct2); + } catch (error) { + console.error('struct1', JSON.stringify(struct1, null, 2)); + console.error('struct2', JSON.stringify(struct2, null, 2)); + throw error; + } + } + }); + }); + + describe('edge performance cases', () => { + test('many small string changes', () => { + const base = 'The quick brown fox jumps over the lazy dog. '; + const long1 = base.repeat(100); + const long2 = long1.replace(/quick/g, 'slow').replace(/brown/g, 'red').replace(/fox/g, 'cat'); + + assertDiff(long1, long2); + }); + + test('single change in large array', () => { + const large = Array.from({length: 5000}, (_, i) => i); + const largeModified = [...large]; + largeModified[2500] = -1; // Single change in the middle + + assertDiff(large, largeModified); + }); + + test('single property change in large object', () => { + const large: Record = {}; + for (let i = 0; i < 1000; i++) { + large[`prop${i}`] = i; + } + + const largeModified = {...large}; + largeModified.prop500 = -1; // Single change + + assertDiff(large, largeModified); + }); + + test('patch size efficiency', () => { + // Test that patches are reasonably sized relative to changes + const diffTool = new JsonPatchDiff(); + + // Small change should produce small patch + const smallSrc = {a: 1, b: 2, c: 3}; + const smallDst = {a: 1, b: 20, c: 3}; + const smallPatch = diffTool.diff('', smallSrc, smallDst); + expect(smallPatch.length).toBeLessThanOrEqual(2); + + // Large identical parts should not bloat patch + const largeSrc = Array.from({length: 1000}, (_, i) => i); + const largeDst = [...largeSrc]; + largeDst[500] = -1; + const largePatch = new JsonPatchDiff().diff('', largeSrc, largeDst); + expect(largePatch.length).toBeLessThanOrEqual(5); + }); + }); + + describe('memory and performance monitoring', () => { + test('memory usage with large documents', () => { + // This test ensures we don't have memory leaks with large documents + const iterations = 10; + + for (let i = 0; i < iterations; i++) { + const large1 = RandomJson.generate({nodeCount: 100}); + const large2 = RandomJson.generate({nodeCount: 100}); + + const diffTool = new JsonPatchDiff(); + const patch = diffTool.diff('', large1, large2); + + // Basic sanity check + expect(Array.isArray(patch)).toBe(true); + expect(patch.length).toBeGreaterThanOrEqual(0); + } + }); + + test('time complexity reasonable for large inputs', () => { + const startTime = Date.now(); + + // Reduced size for CI stability - still tests performance but more realistic + const large1 = Array.from({length: 1500}, (_, i) => ({ + id: i, + data: `item-${i}`, + value: Math.random(), + })); + + // Only modify a subset to avoid worst-case diff scenario + const large2 = large1.map((item, i) => ({ + ...item, + data: i % 3 === 0 ? item.data.toUpperCase() : item.data, + })); + + assertDiff(large1, large2); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should complete in reasonable time (less than 10 seconds) + expect(duration).toBeLessThan(10000); + }); + }); +}); diff --git a/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-string.spec.ts b/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-string.spec.ts new file mode 100644 index 0000000000..35fec71df7 --- /dev/null +++ b/packages/json-joy/src/json-patch-diff/__tests__/JsonPatchDiff-string.spec.ts @@ -0,0 +1,301 @@ +import {assertDiff} from './util'; +import {RandomJson} from '@jsonjoy.com/json-random'; + +describe('String diff functionality', () => { + describe('basic string operations', () => { + test('character insertion at beginning', () => { + assertDiff('hello', 'xhello'); + }); + + test('character insertion at middle', () => { + assertDiff('hello', 'helxlo'); + }); + + test('character insertion at end', () => { + assertDiff('hello', 'hellox'); + }); + + test('character deletion at beginning', () => { + assertDiff('hello', 'ello'); + }); + + test('character deletion at middle', () => { + assertDiff('hello', 'helo'); + }); + + test('character deletion at end', () => { + assertDiff('hello', 'hell'); + }); + + test('character replacement', () => { + assertDiff('hello', 'hxllo'); + assertDiff('hello', 'xello'); + assertDiff('hello', 'hellx'); + }); + + test('multiple operations', () => { + assertDiff('hello world', 'Hello, World!'); + assertDiff('The quick brown fox', 'A slow red fox'); + assertDiff('Lorem ipsum', 'Lorem dolor'); + }); + }); + + describe('unicode string handling', () => { + test('unicode character insertion', () => { + assertDiff('café', 'café ☕'); + assertDiff('hello', 'hello 🌍'); + }); + + test('unicode character deletion', () => { + assertDiff('café ☕', 'café'); + assertDiff('hello 🌍', 'hello'); + }); + + test('unicode character replacement', () => { + assertDiff('🌟', '⭐'); + assertDiff('café', 'cafē'); + }); + + test('emoji modifications', () => { + assertDiff('👋 Hello', '👋 Hi'); + assertDiff('🚀 Launch', '🎯 Target'); + }); + + test('complex unicode sequences', () => { + assertDiff('👨‍👩‍👧‍👦', '👨‍👩‍👧'); + assertDiff('🏳️‍🌈', '🏴‍☠️'); + }); + + test('chinese characters', () => { + assertDiff('你好', '您好'); + assertDiff('北京', '上海'); + assertDiff('学习中文', '学习英文'); + }); + + test('arabic characters', () => { + assertDiff('مرحبا', 'أهلا'); + assertDiff('العربية', 'الإنجليزية'); + }); + + test('mixed scripts', () => { + assertDiff('Hello 你好 مرحبا', 'Hi 您好 أهلا'); + assertDiff('English中文العربية', 'Français中国话فارسی'); + }); + }); + + describe('special characters', () => { + test('whitespace handling', () => { + assertDiff('hello world', 'hello world'); + assertDiff('hello\tworld', 'hello world'); + assertDiff('hello\nworld', 'hello\r\nworld'); + }); + + test('punctuation changes', () => { + assertDiff('Hello, world!', 'Hello world.'); + assertDiff('Test?', 'Test!'); + assertDiff('(parentheses)', '[brackets]'); + }); + + test('quotes and escapes', () => { + assertDiff('"quoted"', "'quoted'"); + assertDiff("It's working", 'It"s working'); + assertDiff('C:\\\\path\\\\file', 'C:/path/file'); + }); + + test('control characters', () => { + assertDiff('hello\u0000world', 'hello world'); + assertDiff('line1\u0001line2', 'line1\u0002line2'); + }); + }); + + describe('performance with large strings', () => { + test('large string insertions', () => { + const base = 'Hello world!'; + const large = 'x'.repeat(1000); + assertDiff(base, base + large); + assertDiff(large + base, base); + }); + + test('large string deletions', () => { + const large = 'x'.repeat(1000); + const small = 'Hello world!'; + assertDiff(large + small, small); + assertDiff(small + large, small); + }); + + test('modifications in large strings', () => { + const prefix = 'a'.repeat(500); + const suffix = 'b'.repeat(500); + assertDiff(prefix + 'ORIGINAL' + suffix, prefix + 'MODIFIED' + suffix); + }); + + test('many small changes in large string', () => { + let str1 = ''; + let str2 = ''; + for (let i = 0; i < 100; i++) { + str1 += `word${i} `; + str2 += `WORD${i} `; + } + assertDiff(str1, str2); + }); + }); + + describe('edge cases', () => { + test('empty string operations', () => { + assertDiff('', 'hello'); + assertDiff('hello', ''); + assertDiff('', ''); + }); + + test('single character strings', () => { + assertDiff('a', 'b'); + assertDiff('a', ''); + assertDiff('', 'a'); + }); + + test('repeated characters', () => { + assertDiff('aaa', 'aaaa'); + assertDiff('aaaa', 'aaa'); + assertDiff('aaa', 'bbb'); + }); + + test('palindromes', () => { + assertDiff('racecar', 'racekar'); + assertDiff('level', 'levels'); + assertDiff('madam', 'madams'); + }); + + test('similar patterns', () => { + assertDiff('abcabc', 'abcabcd'); + assertDiff('123123', '123124'); + assertDiff('ababab', 'acacac'); + }); + }); + + describe('random string fuzzing', () => { + const iterations = 50; + + test('random string pairs', () => { + for (let i = 0; i < iterations; i++) { + const str1 = RandomJson.genString(Math.floor(Math.random() * 100)); + const str2 = RandomJson.genString(Math.floor(Math.random() * 100)); + try { + assertDiff(str1, str2); + } catch (error) { + console.error('str1', JSON.stringify(str1)); + console.error('str2', JSON.stringify(str2)); + throw error; + } + } + }); + + test('modified versions of same string', () => { + for (let i = 0; i < iterations; i++) { + const original = RandomJson.genString(Math.floor(Math.random() * 50) + 10); + let modified = original; + + // Apply random modifications + const operations = Math.floor(Math.random() * 5) + 1; + for (let j = 0; j < operations; j++) { + const opType = Math.floor(Math.random() * 3); + const pos = Math.floor(Math.random() * (modified.length + 1)); + + switch (opType) { + case 0: { + // Insert + const charToInsert = String.fromCharCode(Math.floor(Math.random() * 26) + 97); + modified = modified.slice(0, pos) + charToInsert + modified.slice(pos); + break; + } + case 1: { + // Delete + if (modified.length > 0) { + const delPos = Math.floor(Math.random() * modified.length); + modified = modified.slice(0, delPos) + modified.slice(delPos + 1); + } + break; + } + case 2: { + // Replace + if (modified.length > 0) { + const replPos = Math.floor(Math.random() * modified.length); + const newChar = String.fromCharCode(Math.floor(Math.random() * 26) + 65); + modified = modified.slice(0, replPos) + newChar + modified.slice(replPos + 1); + } + break; + } + } + } + + try { + assertDiff(original, modified); + } catch (error) { + console.error('original', JSON.stringify(original)); + console.error('modified', JSON.stringify(modified)); + throw error; + } + } + }); + + test('unicode string fuzzing', () => { + const unicodeRanges = [ + [0x0020, 0x007f], // Basic Latin + [0x00a0, 0x00ff], // Latin-1 Supplement + [0x0100, 0x017f], // Latin Extended-A + [0x4e00, 0x9fff], // CJK Unified Ideographs + [0x1f600, 0x1f64f], // Emoticons + [0x1f300, 0x1f5ff], // Misc Symbols and Pictographs + ]; + + for (let i = 0; i < iterations; i++) { + let str1 = ''; + let str2 = ''; + const length1 = Math.floor(Math.random() * 20) + 1; + const length2 = Math.floor(Math.random() * 20) + 1; + + for (let j = 0; j < length1; j++) { + const range = unicodeRanges[Math.floor(Math.random() * unicodeRanges.length)]; + const code = Math.floor(Math.random() * (range[1] - range[0] + 1)) + range[0]; + str1 += String.fromCodePoint(code); + } + + for (let j = 0; j < length2; j++) { + const range = unicodeRanges[Math.floor(Math.random() * unicodeRanges.length)]; + const code = Math.floor(Math.random() * (range[1] - range[0] + 1)) + range[0]; + str2 += String.fromCodePoint(code); + } + + try { + assertDiff(str1, str2); + } catch (error) { + console.error('str1', JSON.stringify(str1)); + console.error('str2', JSON.stringify(str2)); + throw error; + } + } + }); + }); + + describe('string diffs in data structures', () => { + test('string changes in objects', () => { + assertDiff({message: 'Hello world'}, {message: 'Hello beautiful world'}); + + assertDiff({title: 'Original Title', body: 'Some content'}, {title: 'Updated Title', body: 'Modified content'}); + }); + + test('string changes in arrays', () => { + assertDiff(['first', 'second', 'third'], ['first', 'SECOND', 'third']); + + assertDiff(['hello world'], ['hello beautiful world']); + }); + + test('nested string changes', () => { + assertDiff( + {user: {name: 'John Doe', bio: 'Software developer'}}, + {user: {name: 'John Smith', bio: 'Senior software developer'}}, + ); + + assertDiff([{text: 'Original'}, {text: 'Content'}], [{text: 'Modified'}, {text: 'Content updated'}]); + }); + }); +}); diff --git a/packages/json-joy/src/json-patch-diff/__tests__/util.ts b/packages/json-joy/src/json-patch-diff/__tests__/util.ts index 306fcca028..670fbe2305 100644 --- a/packages/json-joy/src/json-patch-diff/__tests__/util.ts +++ b/packages/json-joy/src/json-patch-diff/__tests__/util.ts @@ -1,5 +1,6 @@ import {JsonPatchDiff} from '../JsonPatchDiff'; import {applyPatch} from '../../json-patch'; +import {RandomJson} from '@jsonjoy.com/json-random'; export const assertDiff = (src: unknown, dst: unknown) => { const srcNested = {src}; @@ -23,3 +24,96 @@ export const randomArray = () => { } return arr; }; + +export const randomString = (maxLength = 50) => { + return RandomJson.genString(Math.floor(Math.random() * maxLength)); +}; + +export const randomMixedArray = (maxLength = 10) => { + const len = Math.floor(Math.random() * maxLength); + const arr: unknown[] = []; + const types = ['number', 'string', 'boolean', 'null', 'object', 'array']; + + for (let i = 0; i < len; i++) { + const type = types[Math.floor(Math.random() * types.length)]; + switch (type) { + case 'number': + arr.push(Math.random() * 1000); + break; + case 'string': + arr.push(randomString(20)); + break; + case 'boolean': + arr.push(Math.random() > 0.5); + break; + case 'null': + arr.push(null); + break; + case 'object': + arr.push(RandomJson.generate({nodeCount: 3})); + break; + case 'array': + arr.push(randomMixedArray(3)); + break; + } + } + return arr; +}; + +export const randomObject = (maxKeys = 10) => { + const keyCount = Math.floor(Math.random() * maxKeys); + const obj: Record = {}; + const possibleKeys = ['a', 'b', 'c', 'test', 'name', 'value', 'id', 'data']; + + for (let i = 0; i < keyCount; i++) { + const key = Math.random() > 0.8 ? `key${i}` : possibleKeys[Math.floor(Math.random() * possibleKeys.length)]; + obj[key] = RandomJson.generate({nodeCount: 2}); + } + return obj; +}; + +export const randomNestedStructure = (depth = 3) => { + if (depth <= 0) { + return RandomJson.generate({nodeCount: 1}); + } + + const type = Math.random(); + if (type < 0.5) { + // Object + const obj: Record = {}; + const keyCount = Math.floor(Math.random() * 5) + 1; + for (let i = 0; i < keyCount; i++) { + obj[`key${i}`] = randomNestedStructure(depth - 1); + } + return obj; + } else { + // Array + const len = Math.floor(Math.random() * 5) + 1; + const arr: unknown[] = []; + for (let i = 0; i < len; i++) { + arr.push(randomNestedStructure(depth - 1)); + } + return arr; + } +}; + +export const createSimilarDocument = (original: unknown, mutationRate = 0.1): unknown => { + if (Math.random() < mutationRate) { + // Apply random mutation + return RandomJson.generate({nodeCount: 2}); + } + + if (Array.isArray(original)) { + return original.map((item) => createSimilarDocument(item, mutationRate)); + } + + if (original && typeof original === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(original)) { + result[key] = createSimilarDocument(value, mutationRate); + } + return result; + } + + return original; +}; diff --git a/packages/json-joy/tsconfig.json b/packages/json-joy/tsconfig.json index f05dee520e..d383b71b12 100644 --- a/packages/json-joy/tsconfig.json +++ b/packages/json-joy/tsconfig.json @@ -2,5 +2,5 @@ "extends": "../../tsconfig.json", "compilerOptions": { }, - "include": ["src", "../json-path/src"] + "include": ["src"] }