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 applyPatchHelper( Map original, Map diff, ) { final clone = Map.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 && originalVal is Map) { 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()); }); 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 _generateRandomMap(Random r, {int depth = 2}) { final map = {}; 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 _mutateMap(Random r, Map original) { // Deep copy via JSON final clone = jsonDecode(jsonEncode(original)) as Map; 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) { 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; }