Skip to content

Commit 5fdd53c

Browse files
authored
Merge pull request #945 from streamich/line-diff
Line diff/patch improvements
2 parents ff68fc5 + 21817eb commit 5fdd53c

File tree

7 files changed

+789
-251
lines changed

7 files changed

+789
-251
lines changed

biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"complexity": {
4242
"noStaticOnlyClass": "off",
4343
"useOptionalChain": "off",
44-
"noCommaOperator": "off"
44+
"noCommaOperator": "off",
45+
"noUselessLabel": "off"
4546
},
4647
"security": {
4748
"noGlobalEval": "off"

packages/json-joy/src/util/diff/__tests__/bin.spec.ts

Lines changed: 259 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {b} from '@jsonjoy.com/util/lib/buffers/b';
2-
import {toStr, toBin, diff, src, dst} from '../bin';
3-
import {PATCH_OP_TYPE} from '../str';
2+
import {toStr, toBin, diff, src, dst, apply} from '../bin';
3+
import {PATCH_OP_TYPE, invert} from '../str';
44

55
describe('toHex()', () => {
66
test('can convert buffer to string', () => {
@@ -14,6 +14,29 @@ describe('toHex()', () => {
1414
const hex = toStr(buffer);
1515
expect(hex).toBe('\x00\x7f\xff');
1616
});
17+
18+
test('handles empty buffer', () => {
19+
const buffer = b();
20+
const hex = toStr(buffer);
21+
expect(hex).toBe('');
22+
});
23+
24+
test('handles single byte buffer', () => {
25+
const buffer = b(42);
26+
const hex = toStr(buffer);
27+
expect(hex).toBe('\x2a');
28+
});
29+
30+
test('handles all byte values', () => {
31+
const buffer = new Uint8Array(256);
32+
for (let i = 0; i < 256; i++) {
33+
buffer[i] = i;
34+
}
35+
const hex = toStr(buffer);
36+
expect(hex.length).toBe(256);
37+
expect(hex.charCodeAt(0)).toBe(0);
38+
expect(hex.charCodeAt(255)).toBe(255);
39+
});
1740
});
1841

1942
describe('fromHex()', () => {
@@ -26,6 +49,23 @@ describe('fromHex()', () => {
2649
const buffer = toBin('\x00\x7f\xff');
2750
expect(buffer).toEqual(b(0, 127, 255));
2851
});
52+
53+
test('handles empty string', () => {
54+
const buffer = toBin('');
55+
expect(buffer).toEqual(b());
56+
});
57+
58+
test('handles single character', () => {
59+
const buffer = toBin('\x2a');
60+
expect(buffer).toEqual(b(42));
61+
});
62+
63+
test('round-trip conversion', () => {
64+
const originalBuffer = b(0, 1, 127, 128, 254, 255);
65+
const hex = toStr(originalBuffer);
66+
const convertedBuffer = toBin(hex);
67+
expect(convertedBuffer).toEqual(originalBuffer);
68+
});
2969
});
3070

3171
describe('diff()', () => {
@@ -66,4 +106,221 @@ describe('diff()', () => {
66106
expect(src(patch1)).toEqual(b(1, 2, 3));
67107
expect(dst(patch1)).toEqual(b(2, 3, 4));
68108
});
109+
110+
test('handles empty buffers', () => {
111+
const patch1 = diff(b(), b(1, 2, 3));
112+
expect(patch1).toEqual([[PATCH_OP_TYPE.INS, toStr(b(1, 2, 3))]]);
113+
expect(src(patch1)).toEqual(b());
114+
expect(dst(patch1)).toEqual(b(1, 2, 3));
115+
116+
const patch2 = diff(b(1, 2, 3), b());
117+
expect(patch2).toEqual([[PATCH_OP_TYPE.DEL, toStr(b(1, 2, 3))]]);
118+
expect(src(patch2)).toEqual(b(1, 2, 3));
119+
expect(dst(patch2)).toEqual(b());
120+
121+
const patch3 = diff(b(), b());
122+
expect(patch3).toEqual([]);
123+
expect(src(patch3)).toEqual(b());
124+
expect(dst(patch3)).toEqual(b());
125+
});
126+
127+
test('handles null bytes', () => {
128+
const patch1 = diff(b(0, 0, 0), b(0, 1, 0));
129+
expect(src(patch1)).toEqual(b(0, 0, 0));
130+
expect(dst(patch1)).toEqual(b(0, 1, 0));
131+
132+
const patch2 = diff(b(1, 2, 3), b(0, 1, 2, 3, 0));
133+
expect(src(patch2)).toEqual(b(1, 2, 3));
134+
expect(dst(patch2)).toEqual(b(0, 1, 2, 3, 0));
135+
});
136+
137+
test('handles maximum byte values', () => {
138+
const patch1 = diff(b(255, 255), b(255, 254, 255));
139+
expect(src(patch1)).toEqual(b(255, 255));
140+
expect(dst(patch1)).toEqual(b(255, 254, 255));
141+
142+
const patch2 = diff(b(0, 255), b(255, 0));
143+
expect(src(patch2)).toEqual(b(0, 255));
144+
expect(dst(patch2)).toEqual(b(255, 0));
145+
});
146+
147+
test('handles repetitive binary patterns', () => {
148+
const pattern1 = b(170, 170, 170, 170); // 10101010 pattern
149+
const pattern2 = b(170, 170, 85, 170); // 10101010, 10101010, 01010101, 10101010
150+
const patch = diff(pattern1, pattern2);
151+
expect(src(patch)).toEqual(pattern1);
152+
expect(dst(patch)).toEqual(pattern2);
153+
154+
const alternating1 = b(1, 0, 1, 0, 1, 0);
155+
const alternating2 = b(1, 0, 1, 1, 1, 0);
156+
const patch2 = diff(alternating1, alternating2);
157+
expect(src(patch2)).toEqual(alternating1);
158+
expect(dst(patch2)).toEqual(alternating2);
159+
});
160+
161+
test('handles large binary differences', () => {
162+
const large1 = new Uint8Array(100).fill(42);
163+
const large2 = new Uint8Array(100).fill(43);
164+
const patch = diff(large1, large2);
165+
expect(src(patch)).toEqual(large1);
166+
expect(dst(patch)).toEqual(large2);
167+
});
168+
169+
test('handles single byte arrays', () => {
170+
const patch1 = diff(b(1), b(2));
171+
expect(src(patch1)).toEqual(b(1));
172+
expect(dst(patch1)).toEqual(b(2));
173+
174+
const patch2 = diff(b(0), b(255));
175+
expect(src(patch2)).toEqual(b(0));
176+
expect(dst(patch2)).toEqual(b(255));
177+
});
178+
});
179+
180+
describe('apply()', () => {
181+
test('applies binary patches correctly', () => {
182+
const src1 = b(1, 2, 3, 4, 5);
183+
const dst1 = b(1, 0, 3, 4, 6);
184+
const patch = diff(src1, dst1);
185+
186+
const result = src1.slice(); // copy
187+
const insertions: {pos: number; data: Uint8Array}[] = [];
188+
const deletions: {pos: number; len: number}[] = [];
189+
190+
apply(
191+
patch,
192+
result.length,
193+
(pos, data) => {
194+
insertions.push({pos, data});
195+
},
196+
(pos, len) => {
197+
deletions.push({pos, len});
198+
},
199+
);
200+
201+
// Apply deletions and insertions to verify the logic works
202+
// (Note: this is just testing the callback mechanism)
203+
expect(insertions.length + deletions.length).toBeGreaterThan(0);
204+
});
205+
206+
test('handles empty buffer patches', () => {
207+
const patch1 = diff(b(), b(1, 2, 3));
208+
let insertCount = 0;
209+
let deleteCount = 0;
210+
211+
apply(
212+
patch1,
213+
0,
214+
(pos, data) => {
215+
insertCount++;
216+
expect(data).toEqual(b(1, 2, 3));
217+
expect(pos).toBe(0);
218+
},
219+
(pos, len) => {
220+
deleteCount++;
221+
},
222+
);
223+
224+
expect(insertCount).toBe(1);
225+
expect(deleteCount).toBe(0);
226+
});
227+
});
228+
229+
describe('Binary edge cases and stress tests', () => {
230+
test('handles binary data that looks like text', () => {
231+
// Binary data that coincidentally forms valid UTF-8
232+
const text1 = new TextEncoder().encode('Hello World');
233+
const text2 = new TextEncoder().encode('Hello Universe');
234+
235+
const patch = diff(text1, text2);
236+
expect(src(patch)).toEqual(text1);
237+
expect(dst(patch)).toEqual(text2);
238+
});
239+
240+
test('handles large binary arrays efficiently', () => {
241+
const large1 = new Uint8Array(1000);
242+
const large2 = new Uint8Array(1000);
243+
244+
// Fill with different patterns
245+
for (let i = 0; i < 1000; i++) {
246+
large1[i] = i % 256;
247+
large2[i] = (i + 1) % 256;
248+
}
249+
250+
const startTime = Date.now();
251+
const patch = diff(large1, large2);
252+
const endTime = Date.now();
253+
254+
expect(endTime - startTime).toBeLessThan(1000); // Should be fast
255+
expect(src(patch)).toEqual(large1);
256+
expect(dst(patch)).toEqual(large2);
257+
});
258+
259+
test('handles binary patterns with many repetitions', () => {
260+
const pattern1 = new Uint8Array(100);
261+
const pattern2 = new Uint8Array(100);
262+
263+
// Create repetitive patterns
264+
for (let i = 0; i < 100; i++) {
265+
pattern1[i] = i % 4; // 0,1,2,3,0,1,2,3...
266+
pattern2[i] = (i + 1) % 4; // 1,2,3,0,1,2,3,0...
267+
}
268+
269+
const patch = diff(pattern1, pattern2);
270+
expect(src(patch)).toEqual(pattern1);
271+
expect(dst(patch)).toEqual(pattern2);
272+
});
273+
274+
test('handles binary inversion', () => {
275+
const buf1 = b(1, 2, 3, 4, 5);
276+
const buf2 = b(1, 0, 3, 4, 6);
277+
278+
const patch = diff(buf1, buf2);
279+
const inverted = invert(patch);
280+
281+
// Inverted patch should transform buf2 back to buf1
282+
expect(src(inverted)).toEqual(buf2);
283+
expect(dst(inverted)).toEqual(buf1);
284+
});
285+
286+
test('handles mixed null and non-null bytes', () => {
287+
const mixed1 = b(0, 255, 0, 128, 0);
288+
const mixed2 = b(255, 0, 255, 0, 255);
289+
290+
const patch = diff(mixed1, mixed2);
291+
expect(src(patch)).toEqual(mixed1);
292+
expect(dst(patch)).toEqual(mixed2);
293+
});
294+
295+
test('validates conversion consistency across operations', () => {
296+
const testBuffers = [
297+
b(),
298+
b(0),
299+
b(255),
300+
b(0, 255),
301+
b(255, 0),
302+
b(1, 2, 3, 4, 5),
303+
new Uint8Array(256).map((_, i) => i), // All possible byte values
304+
];
305+
306+
for (let i = 0; i < testBuffers.length; i++) {
307+
for (let j = 0; j < testBuffers.length; j++) {
308+
if (i === j) continue;
309+
310+
const buf1 = testBuffers[i];
311+
const buf2 = testBuffers[j];
312+
const patch = diff(buf1, buf2);
313+
314+
// Verify conversion consistency
315+
expect(src(patch)).toEqual(buf1);
316+
expect(dst(patch)).toEqual(buf2);
317+
318+
// Verify round-trip conversion
319+
const str1 = toStr(buf1);
320+
const str2 = toStr(buf2);
321+
expect(toBin(str1)).toEqual(buf1);
322+
expect(toBin(str2)).toEqual(buf2);
323+
}
324+
}
325+
});
69326
});

packages/json-joy/src/util/diff/__tests__/line-fuzzer.spec.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {RandomJson} from '@jsonjoy.com/util/lib/json-random';
22
import {assertDiff} from './line';
3+
import {Fuzzer} from '@jsonjoy.com/util/lib/Fuzzer';
34

4-
const iterations = 1000;
5+
const iterations = 100;
56
const minElements = 2;
67
const maxElements = 6;
78

@@ -28,3 +29,39 @@ test('produces valid patch', () => {
2829
}
2930
}
3031
});
32+
33+
const generateString = (length: number): string => {
34+
let str = '';
35+
for (let i = 0; i < length; i++) str += Fuzzer.randomInt(0, 4);
36+
return str;
37+
};
38+
39+
const generateArray = (length: number = Fuzzer.randomInt(0, 5)): string[] => {
40+
const arr: string[] = [];
41+
for (let i = 0; i < length; i++) {
42+
const str = generateString(Fuzzer.randomInt(0, 6));
43+
arr.push(str);
44+
}
45+
return arr;
46+
};
47+
48+
test('produces valid patch - 2', () => {
49+
for (let i = 0; i < 1000; i++) {
50+
const src: string[] = generateArray();
51+
const dst: string[] = generateArray();
52+
try {
53+
assertDiff(src, dst);
54+
} catch (error) {
55+
console.log('SRC', src);
56+
console.log('DST', dst);
57+
throw error;
58+
}
59+
try {
60+
assertDiff(dst, src);
61+
} catch (error) {
62+
console.log('SRC', dst);
63+
console.log('DST', src);
64+
throw error;
65+
}
66+
}
67+
});

0 commit comments

Comments
 (0)