adding packages

This commit is contained in:
2026-01-15 14:38:46 -08:00
parent ef86e3ab6a
commit 6869cf47e5
5253 changed files with 726695 additions and 34 deletions
@@ -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;
}