adding packages
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import 'package:diff_algorithims/hybrid_diff.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('HybridDiffer Structural Tests (Myers)', () {
|
||||
test('Identical lists return no changes', () {
|
||||
final list = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
{'id': 2, 'val': 'b'},
|
||||
];
|
||||
final changes = HybridDiffer.diff(list, list, idField: 'id');
|
||||
|
||||
// We expect equal nodes or no nodes depending on filtering.
|
||||
// The implementation returns Equal nodes for matches.
|
||||
expect(changes.every((c) => c.op == ChangeOp.equal), isTrue);
|
||||
expect(changes.length, equals(2));
|
||||
});
|
||||
|
||||
test('Detects simple insertion', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
{'id': 2, 'val': 'b'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
|
||||
expect(changes.length, equals(2));
|
||||
expect(changes[0].op, equals(ChangeOp.equal));
|
||||
expect(changes[1].op, equals(ChangeOp.insert));
|
||||
expect(changes[1].key, equals('2'));
|
||||
});
|
||||
|
||||
test('Detects simple deletion', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
{'id': 2, 'val': 'b'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'val': 'a'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
|
||||
expect(changes.length, equals(2));
|
||||
expect(changes[0].op, equals(ChangeOp.equal));
|
||||
expect(changes[1].op, equals(ChangeOp.delete));
|
||||
expect(changes[1].key, equals('2'));
|
||||
});
|
||||
|
||||
test('Detects moves (Delete + Insert)', () {
|
||||
// Myers detects moves as a delete of the old position and insert at new
|
||||
final oldList = [
|
||||
{'id': 1},
|
||||
{'id': 2},
|
||||
{'id': 3},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1},
|
||||
{'id': 3},
|
||||
{'id': 2},
|
||||
]; // 2 moved to end
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
|
||||
// Expected: Equal(1), Equal(3), Insert(2), Delete(2) OR Equal(1), Delete(2), Equal(3), Insert(2)
|
||||
// Myers usually prefers deletes first if weights are equal, but let's check operations.
|
||||
final ops = changes.map((c) => c.op).toList();
|
||||
|
||||
expect(ops, contains(ChangeOp.insert));
|
||||
expect(ops, contains(ChangeOp.delete));
|
||||
|
||||
final insertNode = changes.firstWhere((c) => c.op == ChangeOp.insert);
|
||||
final deleteNode = changes.firstWhere((c) => c.op == ChangeOp.delete);
|
||||
|
||||
expect(insertNode.key, equals(deleteNode.key));
|
||||
expect(['2', '3'], contains(insertNode.key));
|
||||
});
|
||||
});
|
||||
|
||||
group('HybridDiffer Content Tests (Deep Diff)', () {
|
||||
test('Detects modification in primitive fields', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'name': 'Alice'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'name': 'Bob'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
|
||||
expect(changes.length, equals(1));
|
||||
final mod = changes.first;
|
||||
|
||||
expect(mod.op, equals(ChangeOp.modify));
|
||||
expect(mod.children, isNotNull);
|
||||
expect(mod.children!.length, equals(1));
|
||||
|
||||
final fieldChange = mod.children!.first;
|
||||
expect(fieldChange.key, equals('name'));
|
||||
expect(fieldChange.oldValue, equals('Alice'));
|
||||
expect(fieldChange.newValue, equals('Bob'));
|
||||
});
|
||||
|
||||
test('Detects nested map changes recursively', () {
|
||||
final oldList = [
|
||||
{
|
||||
'id': 1,
|
||||
'meta': {'ver': 1, 'author': 'me'},
|
||||
},
|
||||
];
|
||||
final newList = [
|
||||
{
|
||||
'id': 1,
|
||||
'meta': {'ver': 2, 'author': 'me'},
|
||||
},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
final rootMod = changes.first;
|
||||
|
||||
// Look inside the 'meta' field
|
||||
final metaMod = rootMod.children!.firstWhere((c) => c.key == 'meta');
|
||||
expect(metaMod.op, equals(ChangeOp.modify));
|
||||
|
||||
// Look inside 'ver' field
|
||||
final verMod = metaMod.children!.firstWhere((c) => c.key == 'ver');
|
||||
expect(verMod.oldValue, equals(1));
|
||||
expect(verMod.newValue, equals(2));
|
||||
});
|
||||
|
||||
test('Handles null values correctly', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'val': null},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'val': 'not null'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
final fieldChange = changes.first.children!.first;
|
||||
|
||||
expect(fieldChange.key, equals('val'));
|
||||
expect(fieldChange.oldValue, isNull);
|
||||
expect(fieldChange.newValue, equals('not null'));
|
||||
});
|
||||
});
|
||||
|
||||
group('String Splice Optimization', () {
|
||||
test('Generates TextSplice for string changes', () {
|
||||
final oldList = [
|
||||
{'id': 1, 'text': 'Hello World'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'text': 'Hello Flutter World'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
final fieldChange = changes.first.children!.first;
|
||||
|
||||
expect(fieldChange.key, equals('text'));
|
||||
expect(fieldChange.splice, isNotNull);
|
||||
|
||||
// "Hello " (len 6) match. Insert "Flutter ".
|
||||
expect(fieldChange.splice!.index, equals(6));
|
||||
expect(fieldChange.splice!.deleteCount, equals(0));
|
||||
expect(fieldChange.splice!.insertText, equals('Flutter '));
|
||||
});
|
||||
|
||||
test('Handles Unicode Surrogate Pairs (Emoji Safety)', () {
|
||||
// 🤚 is \uD83E\uDD1A (2 code units)
|
||||
final oldList = [
|
||||
{'id': 1, 'text': 'Hi 🤚'},
|
||||
];
|
||||
final newList = [
|
||||
{'id': 1, 'text': 'Hi 🤚 there'},
|
||||
];
|
||||
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
final fieldChange = changes.first.children!.first;
|
||||
|
||||
expect(fieldChange.splice, isNotNull);
|
||||
// Ensure we didn't split the emoji. The index should differ by proper length.
|
||||
// 'Hi ' is 3. Emoji is 2. Total 5.
|
||||
expect(fieldChange.splice!.index, greaterThanOrEqualTo(5));
|
||||
expect(fieldChange.splice!.insertText, contains('there'));
|
||||
});
|
||||
});
|
||||
|
||||
group('Performance / Stress Test', () {
|
||||
test('Handles large lists efficiently', () {
|
||||
// Generate 5000 items
|
||||
final oldList = List<Map<String, dynamic>>.generate(
|
||||
5000,
|
||||
(i) => {'id': i, 'val': 'item $i'},
|
||||
);
|
||||
final newList = List<Map<String, dynamic>>.from(oldList);
|
||||
|
||||
// Make 3 changes
|
||||
newList.removeAt(100); // Delete
|
||||
newList.insert(4000, {'id': 9999, 'val': 'new'}); // Insert
|
||||
// Index 2500 shifted to 2499
|
||||
newList[2499] = {'id': 2500, 'val': 'CHANGED'}; // Modify
|
||||
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final changes = HybridDiffer.diff(oldList, newList, idField: 'id');
|
||||
stopwatch.stop();
|
||||
|
||||
print('Diff 5000 items took: ${stopwatch.elapsedMilliseconds}ms');
|
||||
|
||||
final deletes = changes.where((c) => c.op == ChangeOp.delete);
|
||||
final inserts = changes.where((c) => c.op == ChangeOp.insert);
|
||||
final modifies = changes.where((c) => c.op == ChangeOp.modify);
|
||||
|
||||
expect(deletes.length, equals(1));
|
||||
expect(inserts.length, equals(1));
|
||||
expect(modifies.length, equals(1));
|
||||
|
||||
// Ensure it's reasonably fast (adjust threshold for your machine)
|
||||
expect(stopwatch.elapsedMilliseconds, lessThan(500));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'dart:math';
|
||||
import 'package:diff_algorithims/myers_diff.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
final differ = FastJsonDiffer();
|
||||
|
||||
final oldData = [
|
||||
{'id': 1, 'val': 'A', 'meta': 'complex_obj'},
|
||||
{'id': 2, 'val': 'B', 'meta': 'complex_obj'},
|
||||
{'id': 3, 'val': 'C', 'meta': 'complex_obj'},
|
||||
];
|
||||
|
||||
final newData = [
|
||||
{'id': 1, 'val': 'A', 'meta': 'complex_obj'},
|
||||
{'id': 3, 'val': 'Z', 'meta': 'complex_obj'}, // Modified (val C -> Z)
|
||||
{'id': 4, 'val': 'D', 'meta': 'complex_obj'}, // Inserted
|
||||
];
|
||||
|
||||
group('FastJsonDiffer Scenarios', () {
|
||||
test('Default (Deep Equality) detects content changes', () {
|
||||
// Good for correctness, slower for large lists.
|
||||
final ops = differ.diff(oldData, newData);
|
||||
|
||||
// Filtering out 'equal' ops to check changes
|
||||
final changes = ops.where((op) => op.type != DiffType.equal).toList();
|
||||
|
||||
// Expected changes:
|
||||
// 1. Delete id:2
|
||||
// 2. Delete id:3 (old version)
|
||||
// 3. Insert id:3 (new version)
|
||||
// 4. Insert id:4
|
||||
|
||||
expect(changes.length, equals(4));
|
||||
|
||||
expect(
|
||||
changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// ID 3 changed, so it should appear as both delete and insert
|
||||
expect(
|
||||
changes.any(
|
||||
(op) =>
|
||||
op.type == DiffType.delete &&
|
||||
op.data['id'] == 3 &&
|
||||
op.data['val'] == 'C',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
changes.any(
|
||||
(op) =>
|
||||
op.type == DiffType.insert &&
|
||||
op.data['id'] == 3 &&
|
||||
op.data['val'] == 'Z',
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Optimized (ID + Val Check) behaves like Deep Equality for relevant fields',
|
||||
() {
|
||||
// Here we ignore the 'meta' field entirely, but 'val' changed so result is same as above.
|
||||
final ops = differ.diff(
|
||||
oldData,
|
||||
newData,
|
||||
keyGenerator: (map) => Object.hash(map['id'], map['val']),
|
||||
);
|
||||
|
||||
final changes = ops.where((op) => op.type != DiffType.equal).toList();
|
||||
|
||||
expect(changes.length, equals(4));
|
||||
|
||||
expect(
|
||||
changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// ID 3 changed val
|
||||
expect(
|
||||
changes.any((op) => op.type == DiffType.delete && op.data['id'] == 3),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
changes.any((op) => op.type == DiffType.insert && op.data['id'] == 3),
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('Ultra Fast (ID Only) detects moves/existence only', () {
|
||||
// If we only care if the ID exists (e.g. for list animations where content updates later)
|
||||
final ops = differ.diff(
|
||||
oldData,
|
||||
newData,
|
||||
keyGenerator: (map) => map['id'] as int,
|
||||
);
|
||||
|
||||
final changes = ops.where((op) => op.type != DiffType.equal).toList();
|
||||
|
||||
// Expected changes:
|
||||
// 1. Delete id:2
|
||||
// 2. Insert id:4
|
||||
// Node 3 should be EQUAL because ID matches
|
||||
|
||||
expect(changes.length, equals(2));
|
||||
|
||||
expect(
|
||||
changes.any((op) => op.type == DiffType.delete && op.data['id'] == 2),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
changes.any((op) => op.type == DiffType.insert && op.data['id'] == 4),
|
||||
isTrue,
|
||||
);
|
||||
|
||||
// Ensure id:3 is considered Equal
|
||||
final id3Ops = ops.where((op) => op.data['id'] == 3).toList();
|
||||
expect(id3Ops.length, equals(1));
|
||||
expect(id3Ops.first.type, equals(DiffType.equal));
|
||||
});
|
||||
});
|
||||
|
||||
group('Fuzz Tests', () {
|
||||
test('Randomized Diff/Patch Consistency (500 Iterations)', () {
|
||||
final r = Random(42);
|
||||
|
||||
for (var i = 0; i < 500; i++) {
|
||||
// 1. Generate two random lists
|
||||
// We make listB a mutation of listA to ensure some overlap
|
||||
final listA = _generateRandomList(r, size: r.nextInt(50) + 10);
|
||||
final listB = _mutateList(r, listA);
|
||||
|
||||
// 2. Diff
|
||||
// Using "Ultra Fast" (ID only) mode since it's cleaner for simple integer maps
|
||||
// or just standard map equality if we use defaults.
|
||||
// Let's use standard map equality for robustness.
|
||||
final ops = differ.diff(listA, listB);
|
||||
|
||||
// 3. Reconstruct List B from Ops
|
||||
final reconstructed = <Map<String, dynamic>>[];
|
||||
|
||||
for (final op in ops) {
|
||||
switch (op.type) {
|
||||
case DiffType.equal:
|
||||
case DiffType.insert:
|
||||
reconstructed.add(op.data);
|
||||
break;
|
||||
case DiffType.delete:
|
||||
// Deletions are skipped in the new list construction
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Verify
|
||||
try {
|
||||
expect(reconstructed, equals(listB));
|
||||
} catch (e) {
|
||||
print('Fuzz Failure at iteration $i');
|
||||
print('List A (len ${listA.length}): $listA');
|
||||
print('List B (len ${listB.length}): $listB');
|
||||
print('Ops: $ops');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fuzz Utils ---
|
||||
|
||||
List<Map<String, dynamic>> _generateRandomList(Random r, {required int size}) {
|
||||
return List.generate(size, (index) {
|
||||
return {
|
||||
'id': r.nextInt(1000), // Random IDs, duplicates possible
|
||||
'val': r.nextInt(100),
|
||||
'content': _randomString(r),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _mutateList(
|
||||
Random r,
|
||||
List<Map<String, dynamic>> original,
|
||||
) {
|
||||
final clone = List<Map<String, dynamic>>.from(original);
|
||||
final mutations = r.nextInt(10) + 1; // 1 to 10 mutations
|
||||
|
||||
for (var m = 0; m < mutations; m++) {
|
||||
if (clone.isEmpty) {
|
||||
clone.add(_generateSingleItem(r));
|
||||
continue;
|
||||
}
|
||||
|
||||
final action = r.nextInt(3);
|
||||
if (action == 0) {
|
||||
// Insert
|
||||
final index = r.nextInt(clone.length + 1);
|
||||
clone.insert(index, _generateSingleItem(r));
|
||||
} else if (action == 1) {
|
||||
// Delete
|
||||
final index = r.nextInt(clone.length);
|
||||
clone.removeAt(index);
|
||||
} else {
|
||||
// Modify (Replace item)
|
||||
final index = r.nextInt(clone.length);
|
||||
clone[index] = _generateSingleItem(r);
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _generateSingleItem(Random r) {
|
||||
return {
|
||||
'id': r.nextInt(1000),
|
||||
'val': r.nextInt(100),
|
||||
// 'content': _randomString(r), // Keep simple
|
||||
};
|
||||
}
|
||||
|
||||
String _randomString(Random r) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz';
|
||||
return List.generate(5, (_) => chars[r.nextInt(chars.length)]).join();
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:diff_algorithims/object_transform.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// =============================================================================
|
||||
// --- HELPER: PATCH APPLICATOR ---
|
||||
// =============================================================================
|
||||
// Since the provided code only included the Generator and UI, we need
|
||||
// this helper in the test suite to verify the Diff actually works "Round Trip".
|
||||
|
||||
Map<String, dynamic> applyPatchHelper(
|
||||
Map<String, dynamic> original,
|
||||
Map<String, dynamic> diff,
|
||||
) {
|
||||
final clone = Map<String, dynamic>.from(original);
|
||||
|
||||
for (final key in diff.keys) {
|
||||
var diffVal = diff[key];
|
||||
final originalVal = clone[key];
|
||||
|
||||
// Handle Deserialized JSON objects from serializeDiff
|
||||
if (diffVal is Map && diffVal.containsKey('_op')) {
|
||||
if (diffVal['_op'] == 'd') {
|
||||
diffVal = const Deleted();
|
||||
} else if (diffVal['_op'] == 's') {
|
||||
diffVal = TextSplice(diffVal['i'], diffVal['d'], diffVal['t']);
|
||||
}
|
||||
}
|
||||
|
||||
if (diffVal is Deleted) {
|
||||
clone.remove(key);
|
||||
} else if (diffVal is TextSplice && originalVal is String) {
|
||||
clone[key] = diffVal.apply(originalVal);
|
||||
} else if (diffVal is Map<String, dynamic> &&
|
||||
originalVal is Map<String, dynamic>) {
|
||||
clone[key] = applyPatchHelper(originalVal, diffVal);
|
||||
} else {
|
||||
clone[key] = diffVal;
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// --- THE TEST SUITE ---
|
||||
// =============================================================================
|
||||
|
||||
void main() {
|
||||
group('1. TextSplice Logic', () {
|
||||
test('Correctly splices into the middle of a string', () {
|
||||
final splice = TextSplice(6, 0, "Dart "); // Insert at index 6
|
||||
final original = "Hello World";
|
||||
final result = splice.apply(original);
|
||||
expect(result, "Hello Dart World");
|
||||
});
|
||||
|
||||
test('Correctly handles deletions', () {
|
||||
final splice = TextSplice(0, 5, ""); // Delete first 5 chars
|
||||
final original = "Hello World";
|
||||
final result = splice.apply(original);
|
||||
expect(result, " World");
|
||||
});
|
||||
|
||||
test('Correctly handles replacements', () {
|
||||
// "brown" starts at 10, length 5. Replace with "red".
|
||||
final splice = TextSplice(10, 5, "red");
|
||||
final original = "The quick brown fox";
|
||||
final result = splice.apply(original);
|
||||
expect(result, "The quick red fox");
|
||||
});
|
||||
|
||||
test('Handles index out of bounds gracefully (Append)', () {
|
||||
final splice = TextSplice(100, 0, "!");
|
||||
final original = "Hi";
|
||||
final result = splice.apply(original);
|
||||
expect(result, "Hi!");
|
||||
});
|
||||
});
|
||||
|
||||
group('2. DiffEngine Basic Logic', () {
|
||||
test('Detects Added Keys', () {
|
||||
final oldMap = {'a': 1};
|
||||
final newMap = {'a': 1, 'b': 2};
|
||||
final diff = DiffEngine.generateDiff(oldMap, newMap);
|
||||
expect(diff, {'b': 2});
|
||||
});
|
||||
|
||||
test('Detects Deleted Keys', () {
|
||||
final oldMap = {'a': 1, 'b': 2};
|
||||
final newMap = {'a': 1};
|
||||
final diff = DiffEngine.generateDiff(oldMap, newMap);
|
||||
expect(diff['b'], isA<Deleted>());
|
||||
});
|
||||
|
||||
test('Detects Value Changes (Primitives)', () {
|
||||
final oldMap = {'a': 1};
|
||||
final newMap = {'a': 99};
|
||||
final diff = DiffEngine.generateDiff(oldMap, newMap);
|
||||
expect(diff, {'a': 99});
|
||||
});
|
||||
|
||||
test('Treats Lists as Atomic Replacements', () {
|
||||
// The engine is designed to replace lists entirely, not diff indices
|
||||
final oldMap = {
|
||||
'tags': ['a', 'b'],
|
||||
};
|
||||
final newMap = {
|
||||
'tags': ['a', 'c'],
|
||||
};
|
||||
final diff = DiffEngine.generateDiff(oldMap, newMap);
|
||||
expect(diff['tags'], ['a', 'c']);
|
||||
});
|
||||
|
||||
test('Recursively Diffs Nested Maps', () {
|
||||
final oldMap = {
|
||||
'meta': {'v': 1, 'active': true},
|
||||
};
|
||||
final newMap = {
|
||||
'meta': {'v': 2, 'active': true},
|
||||
};
|
||||
final diff = DiffEngine.generateDiff(oldMap, newMap);
|
||||
|
||||
expect(diff.containsKey('meta'), true);
|
||||
expect(diff['meta'], {'v': 2}); // Should NOT contain 'active'
|
||||
});
|
||||
});
|
||||
|
||||
group('3. Emoji & Unicode Safety (Crucial)', () {
|
||||
test('Does NOT split a surrogate pair when deleting', () {
|
||||
// 👋 is \uD83D\uDC4B (2 code units)
|
||||
final oldMap = {'msg': "Hi 👋"};
|
||||
final newMap = {'msg': "Hi "};
|
||||
|
||||
final diff = DiffEngine.generateDiff(oldMap, newMap);
|
||||
final splice = diff['msg'] as TextSplice;
|
||||
|
||||
// It should delete 2 units (the whole emoji), not 1
|
||||
expect(splice.deleteCount, 2, reason: "Should delete full emoji");
|
||||
expect(splice.index, 3);
|
||||
});
|
||||
|
||||
test(
|
||||
'Handles changing one emoji to another (Surrogate boundary check)',
|
||||
() {
|
||||
// 👋 (\uD83D\uDC4B) -> 🤚 (\uD83E\uDD1A)
|
||||
// Note: They share the high surrogate \uD83... in some encodings,
|
||||
// or simply look similar. The algorithm must not get confused.
|
||||
final oldMap = {'msg': "A 👋 B"};
|
||||
final newMap = {'msg': "A 🤚 B"};
|
||||
|
||||
final diff = DiffEngine.generateDiff(oldMap, newMap);
|
||||
final splice = diff['msg'] as TextSplice;
|
||||
|
||||
// Should recognize the change at the emoji
|
||||
expect(splice.insertText, "🤚");
|
||||
// Applying it should result in valid string
|
||||
final patched = applyPatchHelper(oldMap, diff);
|
||||
expect(patched['msg'], "A 🤚 B");
|
||||
},
|
||||
);
|
||||
|
||||
test('Handles Complex ZWJ Emojis (Family)', () {
|
||||
// 👨👩👧👦 is 11 chars long
|
||||
final family = "👨👩👧👦";
|
||||
final oldMap = {'icon': "Family: $family"};
|
||||
final newMap = {'icon': "Family: "}; // Deleted
|
||||
|
||||
final diff = DiffEngine.generateDiff(oldMap, newMap);
|
||||
final splice = diff['icon'] as TextSplice;
|
||||
|
||||
// Should delete exactly the length of the emoji + space
|
||||
// Length of "Family: " is 8.
|
||||
expect(splice.index, 8);
|
||||
expect(splice.deleteCount, family.length);
|
||||
});
|
||||
});
|
||||
|
||||
group('4. Serialization', () {
|
||||
test('Serializes TextSplice and Deleted correctly', () {
|
||||
final diff = {
|
||||
'bio': TextSplice(5, 1, "a"),
|
||||
'oldField': const Deleted(),
|
||||
'simple': 123,
|
||||
};
|
||||
|
||||
final jsonStr = serializeDiff(diff);
|
||||
final decoded = jsonDecode(jsonStr);
|
||||
|
||||
expect(decoded['bio']['_op'], 's');
|
||||
expect(decoded['bio']['i'], 5);
|
||||
expect(decoded['oldField']['_op'], 'd');
|
||||
expect(decoded['simple'], 123);
|
||||
});
|
||||
|
||||
test('Round Trip: Serialize -> Deserialize -> Apply', () {
|
||||
final oldState = {'text': "Hello"};
|
||||
final diff = {'text': TextSplice(5, 0, " World")};
|
||||
|
||||
final jsonStr = serializeDiff(diff);
|
||||
final decodedDiff = jsonDecode(jsonStr); // Raw JSON Maps
|
||||
|
||||
// Our helper must handle the raw JSON maps with _op
|
||||
final patched = applyPatchHelper(oldState, decodedDiff);
|
||||
expect(patched['text'], "Hello World");
|
||||
});
|
||||
});
|
||||
|
||||
group('5. Fuzz Testing (Chaos Monkey)', () {
|
||||
// This generates random maps, mutates them, calculates diff,
|
||||
// and verifies that Old + Diff == New.
|
||||
test('Randomized Property Test (1000 iterations)', () {
|
||||
final r = Random(42); // Seed for reproducibility
|
||||
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
final stateA = _generateRandomMap(r, depth: 3);
|
||||
final stateB = _mutateMap(r, stateA);
|
||||
|
||||
try {
|
||||
// 1. Generate
|
||||
final diff = DiffEngine.generateDiff(stateA, stateB);
|
||||
|
||||
// 2. Simulate Network (Serialize/Deserialize)
|
||||
final jsonStr = serializeDiff(diff);
|
||||
final decodedDiff = jsonDecode(jsonStr);
|
||||
|
||||
// 3. Patch
|
||||
final reconstructedB = applyPatchHelper(stateA, decodedDiff);
|
||||
|
||||
// 4. Verify
|
||||
final isEqual = jsonEncode(stateB) == jsonEncode(reconstructedB);
|
||||
if (!isEqual) {
|
||||
fail('''
|
||||
Fuzz Failure at iteration $i
|
||||
Original: $stateA
|
||||
Target: $stateB
|
||||
Diff: $decodedDiff
|
||||
Result: $reconstructedB
|
||||
''');
|
||||
}
|
||||
} catch (e, stack) {
|
||||
fail('Crash at iteration $i: $e\n$stack');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// --- FUZZER UTILITIES ---
|
||||
// =============================================================================
|
||||
|
||||
Map<String, dynamic> _generateRandomMap(Random r, {int depth = 2}) {
|
||||
final map = <String, dynamic>{};
|
||||
final keyCount = r.nextInt(5);
|
||||
|
||||
for (int i = 0; i < keyCount; i++) {
|
||||
final key = 'k$i';
|
||||
if (depth > 0 && r.nextBool()) {
|
||||
map[key] = _generateRandomMap(r, depth: depth - 1);
|
||||
} else {
|
||||
map[key] = _generateRandomValue(r);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
dynamic _generateRandomValue(Random r) {
|
||||
final type = r.nextInt(4);
|
||||
if (type == 0) return r.nextInt(100);
|
||||
if (type == 1) return r.nextBool();
|
||||
if (type == 2) {
|
||||
// Random String with occasional Emoji
|
||||
if (r.nextBool()) return "Test ${r.nextInt(100)}";
|
||||
return "Hello 👋 ${r.nextInt(10)}";
|
||||
}
|
||||
if (type == 3) return [1, 2, 3]; // Simple list
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _mutateMap(Random r, Map<String, dynamic> original) {
|
||||
// Deep copy via JSON
|
||||
final clone = jsonDecode(jsonEncode(original)) as Map<String, dynamic>;
|
||||
final keys = clone.keys.toList();
|
||||
|
||||
// 1. Add new key
|
||||
if (r.nextBool() || keys.isEmpty) {
|
||||
clone['new_${r.nextInt(100)}'] = 'added';
|
||||
return clone; // Return early to keep mutations simple per step
|
||||
}
|
||||
|
||||
// 2. Delete key
|
||||
if (r.nextBool()) {
|
||||
clone.remove(keys[r.nextInt(keys.length)]);
|
||||
return clone;
|
||||
}
|
||||
|
||||
// 3. Modify existing
|
||||
final key = keys[r.nextInt(keys.length)];
|
||||
final val = clone[key];
|
||||
|
||||
if (val is Map<String, dynamic>) {
|
||||
clone[key] = _mutateMap(r, val);
|
||||
} else if (val is String) {
|
||||
// String mutation
|
||||
if (val.isNotEmpty) {
|
||||
clone[key] = val + " appended";
|
||||
} else {
|
||||
clone[key] = "New";
|
||||
}
|
||||
} else {
|
||||
clone[key] = "Changed Primitive";
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
Reference in New Issue
Block a user