adding packages
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,298 @@
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum ChangeOp { insert, delete, modify, equal }
|
||||
|
||||
class DiffNode {
|
||||
final ChangeOp op;
|
||||
final String key;
|
||||
final Object? oldValue;
|
||||
final Object? newValue;
|
||||
final List<DiffNode>? children;
|
||||
final TextSplice? splice;
|
||||
|
||||
DiffNode.insert(this.key, this.newValue)
|
||||
: op = ChangeOp.insert,
|
||||
oldValue = null,
|
||||
children = null,
|
||||
splice = null;
|
||||
|
||||
DiffNode.delete(this.key, this.oldValue)
|
||||
: op = ChangeOp.delete,
|
||||
newValue = null,
|
||||
children = null,
|
||||
splice = null;
|
||||
|
||||
DiffNode.equal(this.key, this.newValue)
|
||||
: op = ChangeOp.equal,
|
||||
oldValue = newValue,
|
||||
children = null,
|
||||
splice = null;
|
||||
|
||||
DiffNode.modify(
|
||||
this.key,
|
||||
this.oldValue,
|
||||
this.newValue, {
|
||||
this.children,
|
||||
this.splice,
|
||||
}) : op = ChangeOp.modify;
|
||||
|
||||
@override
|
||||
String toString() => '$op: $key';
|
||||
}
|
||||
|
||||
class TextSplice {
|
||||
final int index;
|
||||
final int deleteCount;
|
||||
final String insertText;
|
||||
|
||||
TextSplice(this.index, this.deleteCount, this.insertText);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'i': index,
|
||||
'd': deleteCount,
|
||||
't': insertText,
|
||||
};
|
||||
}
|
||||
|
||||
class HybridDiffer {
|
||||
static List<DiffNode> diff(
|
||||
List<Map<String, Object?>> oldList,
|
||||
List<Map<String, Object?>> newList, {
|
||||
required String idField,
|
||||
}) {
|
||||
final oldIds = oldList.map((e) => e[idField]).toList();
|
||||
final newIds = newList.map((e) => e[idField]).toList();
|
||||
|
||||
final structuralOps = _myersDiff(oldIds, newIds);
|
||||
final results = <DiffNode>[];
|
||||
|
||||
for (final op in structuralOps) {
|
||||
if (op.type == _MyersOpType.delete) {
|
||||
final item = oldList[op.oldIndex!];
|
||||
results.add(DiffNode.delete(item[idField].toString(), item));
|
||||
} else if (op.type == _MyersOpType.insert) {
|
||||
final item = newList[op.newIndex!];
|
||||
results.add(DiffNode.insert(item[idField].toString(), item));
|
||||
} else {
|
||||
// Equal ID: Check Content
|
||||
final oldItem = oldList[op.oldIndex!];
|
||||
final newItem = newList[op.newIndex!];
|
||||
final key = newItem[idField].toString();
|
||||
|
||||
if (_areDeepEqual(oldItem, newItem)) {
|
||||
results.add(DiffNode.equal(key, newItem));
|
||||
} else {
|
||||
final fieldChanges = _generateObjectDiff(oldItem, newItem);
|
||||
results.add(
|
||||
DiffNode.modify(key, oldItem, newItem, children: fieldChanges),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
static List<DiffNode> _generateObjectDiff(
|
||||
Map<String, Object?> oldObj,
|
||||
Map<String, Object?> newObj,
|
||||
) {
|
||||
final diffs = <DiffNode>[];
|
||||
final allKeys = {...oldObj.keys, ...newObj.keys}.toList()..sort();
|
||||
|
||||
for (final key in allKeys) {
|
||||
final oldVal = oldObj[key];
|
||||
final newVal = newObj[key];
|
||||
|
||||
if (!oldObj.containsKey(key)) {
|
||||
diffs.add(DiffNode.insert(key, newVal));
|
||||
} else if (!newObj.containsKey(key)) {
|
||||
diffs.add(DiffNode.delete(key, oldVal));
|
||||
} else if (oldVal is Map<String, Object?> &&
|
||||
newVal is Map<String, Object?>) {
|
||||
final nested = _generateObjectDiff(oldVal, newVal);
|
||||
if (nested.isNotEmpty) {
|
||||
diffs.add(DiffNode.modify(key, oldVal, newVal, children: nested));
|
||||
}
|
||||
} else if (oldVal is String && newVal is String) {
|
||||
if (oldVal != newVal) {
|
||||
final splice = _calculateStringSplice(oldVal, newVal);
|
||||
diffs.add(DiffNode.modify(key, oldVal, newVal, splice: splice));
|
||||
}
|
||||
} else {
|
||||
if (!_areDeepEqual(oldVal, newVal)) {
|
||||
diffs.add(DiffNode.modify(key, oldVal, newVal));
|
||||
}
|
||||
}
|
||||
}
|
||||
return diffs;
|
||||
}
|
||||
|
||||
static bool _areDeepEqual(Object? a, Object? b) {
|
||||
if (a == b) return true;
|
||||
if (a is List && b is List) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (!_areDeepEqual(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (a is Map && b is Map) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final key in a.keys) {
|
||||
if (!b.containsKey(key) || !_areDeepEqual(a[key], b[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static TextSplice? _calculateStringSplice(String oldText, String newText) {
|
||||
if (oldText == newText) return null;
|
||||
int start = 0;
|
||||
final minLen = min(oldText.length, newText.length);
|
||||
while (start < minLen &&
|
||||
oldText.codeUnitAt(start) == newText.codeUnitAt(start)) {
|
||||
start++;
|
||||
}
|
||||
|
||||
if (start > 0 &&
|
||||
start < oldText.length &&
|
||||
_isSurrogate(oldText.codeUnitAt(start - 1))) {
|
||||
start--;
|
||||
}
|
||||
|
||||
int oldEnd = oldText.length;
|
||||
int newEnd = newText.length;
|
||||
while (oldEnd > start &&
|
||||
newEnd > start &&
|
||||
oldText.codeUnitAt(oldEnd - 1) == newText.codeUnitAt(newEnd - 1)) {
|
||||
oldEnd--;
|
||||
newEnd--;
|
||||
}
|
||||
|
||||
return TextSplice(start, oldEnd - start, newText.substring(start, newEnd));
|
||||
}
|
||||
|
||||
static bool _isSurrogate(int code) => (code >= 0xD800 && code <= 0xDFFF);
|
||||
|
||||
// --- MYERS ALGORITHM ---
|
||||
|
||||
static List<_MyersOp> _myersDiff(List oldIds, List newIds) {
|
||||
final n = oldIds.length;
|
||||
final m = newIds.length;
|
||||
final max = n + m;
|
||||
final v = Int32List(2 * max + 1)..fillRange(0, 2 * max + 1, -1);
|
||||
final trace = <Int32List>[];
|
||||
|
||||
v[max] = 0;
|
||||
|
||||
for (var d = 0; d <= max; d++) {
|
||||
trace.add(Int32List.fromList(v));
|
||||
for (var k = -d; k <= d; k += 2) {
|
||||
final indexK = max + k;
|
||||
int x;
|
||||
if (d == 0) {
|
||||
x = 0;
|
||||
} else if (k == -d || (k != d && v[indexK - 1] < v[indexK + 1])) {
|
||||
x = v[indexK + 1];
|
||||
} else {
|
||||
x = v[indexK - 1] + 1;
|
||||
}
|
||||
int y = x - k;
|
||||
while (x < n && y < m && oldIds[x] == newIds[y]) {
|
||||
x++;
|
||||
y++;
|
||||
}
|
||||
v[indexK] = x;
|
||||
if (x >= n && y >= m) {
|
||||
return _buildMyersScript(oldIds, newIds, trace, d, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
static List<_MyersOp> _buildMyersScript(
|
||||
List oldIds,
|
||||
List newIds,
|
||||
List<Int32List> trace,
|
||||
int d,
|
||||
int maxOffset,
|
||||
) {
|
||||
var script = <_MyersOp>[];
|
||||
var x = oldIds.length;
|
||||
var y = newIds.length;
|
||||
|
||||
for (var i = d; i > 0; i--) {
|
||||
final k = x - y;
|
||||
final kIndex = maxOffset + k;
|
||||
final prevV = trace[i];
|
||||
final kMinus1 = kIndex - 1;
|
||||
final kPlus1 = kIndex - 1 + 2; // kIndex + 1
|
||||
|
||||
int prevKIndex;
|
||||
final bool pickInsert;
|
||||
|
||||
if (k == -i) {
|
||||
pickInsert = true;
|
||||
} else if (k == i) {
|
||||
pickInsert = false;
|
||||
} else if (prevV[kMinus1] == -1) {
|
||||
pickInsert = true;
|
||||
} else if (prevV[kPlus1] == -1) {
|
||||
pickInsert = false;
|
||||
} else {
|
||||
pickInsert = prevV[kMinus1] < prevV[kPlus1];
|
||||
}
|
||||
|
||||
if (pickInsert) {
|
||||
prevKIndex = kPlus1;
|
||||
} else {
|
||||
prevKIndex = kMinus1;
|
||||
}
|
||||
|
||||
final prevX = prevV[prevKIndex];
|
||||
final isInsert = prevKIndex == kPlus1;
|
||||
final startX = isInsert ? prevX : prevX + 1;
|
||||
|
||||
while (x > startX) {
|
||||
// Snake loop logic
|
||||
script.add(
|
||||
_MyersOp(_MyersOpType.equal, oldIndex: x - 1, newIndex: y - 1),
|
||||
);
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
|
||||
if (isInsert) {
|
||||
// Moved down (Insert)
|
||||
script.add(_MyersOp(_MyersOpType.insert, newIndex: y - 1));
|
||||
y--;
|
||||
} else {
|
||||
// Moved right (Delete)
|
||||
script.add(_MyersOp(_MyersOpType.delete, oldIndex: x - 1));
|
||||
x--;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining snakes
|
||||
while (x > 0 && y > 0) {
|
||||
script.add(
|
||||
_MyersOp(_MyersOpType.equal, oldIndex: x - 1, newIndex: y - 1),
|
||||
);
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
return script.reversed.toList();
|
||||
}
|
||||
}
|
||||
|
||||
enum _MyersOpType { insert, delete, equal }
|
||||
|
||||
class _MyersOp {
|
||||
final _MyersOpType type;
|
||||
final int? oldIndex;
|
||||
final int? newIndex;
|
||||
_MyersOp(this.type, {this.oldIndex, this.newIndex});
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
enum DiffType { insert, delete, equal }
|
||||
|
||||
class DiffOperation<T> {
|
||||
final DiffType type;
|
||||
final T data;
|
||||
final int
|
||||
index; // Index in newList (for insert) or oldList (for delete/equal)
|
||||
|
||||
DiffOperation(this.type, this.data, this.index);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'$type: ${data.toString().substring(0, 20)}... (@$index)';
|
||||
}
|
||||
|
||||
/// A generic-free typedef for the custom hasher to maximize performance
|
||||
typedef JsonHasher = int Function(Map<String, dynamic> item);
|
||||
|
||||
class FastJsonDiffer {
|
||||
static const _deepEquality = DeepCollectionEquality();
|
||||
|
||||
// POOLING: Reusable buffers to prevent GC thrashing during high-freq updates
|
||||
Int32List _vBuffer = Int32List(1024);
|
||||
|
||||
// Storing history for the traceback
|
||||
// We reuse the list container, but we must allocate new Int32Lists for snapshots
|
||||
final List<Int32List> _traceBuffer = [];
|
||||
|
||||
/// Main Diff Method.
|
||||
///
|
||||
/// [keyGenerator] (Optional): Provide this for maximum speed.
|
||||
/// instead of comparing every field, return a hash of specific fields
|
||||
/// (e.g., `(i) => Object.hash(i['id'], i['version'])`).
|
||||
List<DiffOperation<Map<String, dynamic>>> diff(
|
||||
List<Map<String, dynamic>> oldList,
|
||||
List<Map<String, dynamic>> newList, {
|
||||
JsonHasher? keyGenerator,
|
||||
}) {
|
||||
// 1. Identity Check (Optimization)
|
||||
if (identical(oldList, newList)) return [];
|
||||
if (oldList.isEmpty && newList.isEmpty) return [];
|
||||
|
||||
// 2. Generate Proxies (Hashes)
|
||||
// If no keyGenerator is provided, we default to expensive Deep Equality
|
||||
final hasher = keyGenerator ?? (item) => _deepEquality.hash(item);
|
||||
|
||||
final oldHashes = _generateHashes(oldList, hasher);
|
||||
final newHashes = _generateHashes(newList, hasher);
|
||||
|
||||
// 3. Run Myers Algorithm on Ints
|
||||
final rawOps = _diffInts(oldHashes, newHashes);
|
||||
|
||||
// 4. Rehydrate (Map indices back to actual Objects)
|
||||
return rawOps.map((op) {
|
||||
switch (op.type) {
|
||||
case DiffType.insert:
|
||||
return DiffOperation(DiffType.insert, newList[op.index], op.index);
|
||||
case DiffType.delete:
|
||||
return DiffOperation(DiffType.delete, oldList[op.index], op.index);
|
||||
case DiffType.equal:
|
||||
return DiffOperation(DiffType.equal, oldList[op.index], op.index);
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Int32List _generateHashes(
|
||||
List<Map<String, dynamic>> list,
|
||||
JsonHasher hasher,
|
||||
) {
|
||||
final int len = list.length;
|
||||
final buffer = Int32List(len);
|
||||
for (var i = 0; i < len; i++) {
|
||||
buffer[i] = hasher(list[i]);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// The Core Myers Logic specialized for Int32List.
|
||||
/// This runs purely on stack primitives and typed arrays.
|
||||
List<DiffOperation<int>> _diffInts(Int32List oldList, Int32List newList) {
|
||||
final n = oldList.length;
|
||||
final m = newList.length;
|
||||
final max = n + m;
|
||||
final requiredSize = 2 * max + 1;
|
||||
|
||||
// Grow buffer if necessary
|
||||
if (_vBuffer.length < requiredSize) {
|
||||
_vBuffer = Int32List(requiredSize);
|
||||
}
|
||||
|
||||
_vBuffer.fillRange(0, requiredSize, -1);
|
||||
_traceBuffer.clear();
|
||||
|
||||
_vBuffer[max] = 0;
|
||||
|
||||
for (var d = 0; d <= max; d++) {
|
||||
// Snapshot current V state for traceback.
|
||||
_traceBuffer.add(Int32List.fromList(_vBuffer.sublist(0, requiredSize)));
|
||||
|
||||
for (var k = -d; k <= d; k += 2) {
|
||||
final kIndex = max + k;
|
||||
int x;
|
||||
|
||||
// Choose move: Down (Insertion) or Right (Deletion)
|
||||
if (d == 0) {
|
||||
x = 0;
|
||||
} else if (k == -d ||
|
||||
(k != d && _vBuffer[kIndex - 1] < _vBuffer[kIndex + 1])) {
|
||||
x = _vBuffer[kIndex + 1];
|
||||
} else {
|
||||
x = _vBuffer[kIndex - 1] + 1;
|
||||
}
|
||||
|
||||
int y = x - k;
|
||||
|
||||
// Snake: Move diagonal as long as hashes match
|
||||
while (x < n && y < m && oldList[x] == newList[y]) {
|
||||
x++;
|
||||
y++;
|
||||
}
|
||||
|
||||
_vBuffer[kIndex] = x;
|
||||
|
||||
// Check for completion
|
||||
if (x >= n && y >= m) {
|
||||
return _buildScript(oldList, newList, d, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
List<DiffOperation<int>> _buildScript(
|
||||
Int32List oldList,
|
||||
Int32List newList,
|
||||
int d,
|
||||
int maxOffset,
|
||||
) {
|
||||
var script = <DiffOperation<int>>[];
|
||||
var x = oldList.length;
|
||||
var y = newList.length;
|
||||
|
||||
for (var i = d; i > 0; i--) {
|
||||
final k = x - y;
|
||||
final kIndex = maxOffset + k;
|
||||
final prevV = _traceBuffer[i]; // FIXED: Use trace[i]
|
||||
|
||||
final kMinus1 = kIndex - 1;
|
||||
final kPlus1 = kIndex + 1;
|
||||
|
||||
int prevKIndex;
|
||||
final bool pickInsert;
|
||||
|
||||
// Robust selection logic
|
||||
if (k == -i) {
|
||||
pickInsert = true;
|
||||
} else if (k == i) {
|
||||
pickInsert = false;
|
||||
} else if (prevV[kMinus1] == -1) {
|
||||
pickInsert = true;
|
||||
} else if (prevV[kPlus1] == -1) {
|
||||
pickInsert = false;
|
||||
} else {
|
||||
pickInsert = prevV[kMinus1] < prevV[kPlus1];
|
||||
}
|
||||
|
||||
if (pickInsert) {
|
||||
prevKIndex = kPlus1;
|
||||
} else {
|
||||
prevKIndex = kMinus1;
|
||||
}
|
||||
|
||||
final prevX = prevV[prevKIndex];
|
||||
// Assert specific invariants
|
||||
assert(
|
||||
prevX >= 0,
|
||||
"Myers invariant violated: prevX should effectively never be -1 (sentinel) when accessed from valid path.",
|
||||
);
|
||||
assert(
|
||||
x >= prevX,
|
||||
"Myers invariant violated: x ($x) cannot be less than prevX ($prevX)",
|
||||
);
|
||||
|
||||
final isInsert = prevKIndex == kPlus1;
|
||||
final startX = isInsert ? prevX : prevX + 1;
|
||||
|
||||
// Add Snakes (Equal)
|
||||
while (x > startX) {
|
||||
assert(x > 0, "Backtracking snake cannot go below 0 for x");
|
||||
script.add(DiffOperation(DiffType.equal, 0, x - 1));
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
|
||||
// Add Edit
|
||||
if (isInsert) {
|
||||
assert(y > 0, "Insert operation must have y index > 0 (y=$y)");
|
||||
script.add(DiffOperation(DiffType.insert, 0, y - 1));
|
||||
y--;
|
||||
} else {
|
||||
assert(x > 0, "Delete operation must have x index > 0 (x=$x)");
|
||||
script.add(DiffOperation(DiffType.delete, 0, x - 1));
|
||||
x--;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining snakes
|
||||
while (x > 0 && y > 0) {
|
||||
script.add(DiffOperation(DiffType.equal, 0, x - 1));
|
||||
x--;
|
||||
y--;
|
||||
}
|
||||
|
||||
assert(
|
||||
x == 0 && y == 0,
|
||||
"Backtrack should end at 0,0. Ended at x=$x, y=$y. Traceback logic is flowed.",
|
||||
);
|
||||
|
||||
return script.reversed.toList();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
final differ = FastJsonDiffer();
|
||||
|
||||
// Scenario: API Response or Game State
|
||||
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
|
||||
];
|
||||
|
||||
print('--- 1. Default (Deep Equality) ---');
|
||||
// Good for correctness, slower for large lists.
|
||||
final opsDefault = differ.diff(oldData, newData);
|
||||
|
||||
for (var op in opsDefault) {
|
||||
if (op.type != DiffType.equal) print(op);
|
||||
}
|
||||
// Expected:
|
||||
// Delete: {id: 2...}
|
||||
// Delete: {id: 3, val: C...} (Because val changed, hash changed)
|
||||
// Insert: {id: 3, val: Z...}
|
||||
// Insert: {id: 4...}
|
||||
|
||||
print('\n--- 2. Optimized (ID + Val Check) ---');
|
||||
// Great for high-frequency. We tell the differ EXACTLY what constitutes a change.
|
||||
// Here we ignore the 'meta' field entirely.
|
||||
final opsFast = differ.diff(
|
||||
oldData,
|
||||
newData,
|
||||
keyGenerator: (map) => Object.hash(map['id'], map['val']),
|
||||
);
|
||||
|
||||
for (var op in opsFast) {
|
||||
if (op.type != DiffType.equal) print(op);
|
||||
}
|
||||
|
||||
print('\n--- 3. Ultra Fast (ID Only - Move Detection) ---');
|
||||
// If we only care if the ID exists (e.g. for list animations where content updates later)
|
||||
final opsIdOnly = differ.diff(
|
||||
oldData,
|
||||
newData,
|
||||
keyGenerator: (map) => map['id'] as int, // Direct int cast is fastest
|
||||
);
|
||||
|
||||
for (var op in opsIdOnly) {
|
||||
if (op.type != DiffType.equal) print(op);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'dart:convert';
|
||||
|
||||
// =============================================================================
|
||||
// --- CORE ALGORITHM (The Diff Engine) ---
|
||||
// =============================================================================
|
||||
|
||||
/// Sentinel value for deleted keys
|
||||
class Deleted {
|
||||
const Deleted();
|
||||
Map<String, dynamic> toJson() => {'_op': 'd'};
|
||||
@override
|
||||
String toString() => '<Deleted>';
|
||||
}
|
||||
|
||||
const deleted = Deleted();
|
||||
|
||||
/// Represents a precise text edit (Splice)
|
||||
class TextSplice {
|
||||
final int index;
|
||||
final int deleteCount;
|
||||
final String insertText;
|
||||
|
||||
TextSplice(this.index, this.deleteCount, this.insertText);
|
||||
|
||||
/// Applies the splice to a string
|
||||
String apply(String original) {
|
||||
if (index > original.length) return original + insertText;
|
||||
final start = original.substring(0, index);
|
||||
final end = original.substring(index + deleteCount);
|
||||
return '$start$insertText$end';
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'_op': 's',
|
||||
'i': index,
|
||||
'd': deleteCount,
|
||||
't': insertText,
|
||||
};
|
||||
}
|
||||
|
||||
class DiffEngine {
|
||||
/// Generates a recursive diff between two JSON-like maps
|
||||
static Map<String, dynamic> generateDiff(
|
||||
Map<String, dynamic> oldObj,
|
||||
Map<String, dynamic> newObj,
|
||||
) {
|
||||
final diff = <String, dynamic>{};
|
||||
|
||||
// 1. Walk new keys
|
||||
for (final key in newObj.keys) {
|
||||
final oldVal = oldObj[key];
|
||||
final newVal = newObj[key];
|
||||
|
||||
// New Key Added
|
||||
if (!oldObj.containsKey(key)) {
|
||||
diff[key] = newVal;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursive Map Diff
|
||||
if (oldVal is Map<String, dynamic> && newVal is Map<String, dynamic>) {
|
||||
final nested = generateDiff(oldVal, newVal);
|
||||
if (nested.isNotEmpty) diff[key] = nested;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Smart String Diff (Splice)
|
||||
if (oldVal is String && newVal is String) {
|
||||
final splice = _calculateStringSplice(oldVal, newVal);
|
||||
if (splice != null) diff[key] = splice;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Standard Value Replacement (Primitives or Lists)
|
||||
if (!_areValuesEqual(oldVal, newVal)) {
|
||||
diff[key] = newVal;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Walk old keys to find Deletions
|
||||
for (final key in oldObj.keys) {
|
||||
if (!newObj.containsKey(key)) diff[key] = deleted;
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
/// Calculates the minimal edit (splice) with EMOJI SAFETY
|
||||
static TextSplice? _calculateStringSplice(String oldText, String newText) {
|
||||
if (oldText == newText) return null;
|
||||
|
||||
// A. Find Common Prefix
|
||||
int start = 0;
|
||||
final minLen = (oldText.length < newText.length)
|
||||
? oldText.length
|
||||
: newText.length;
|
||||
|
||||
while (start < minLen &&
|
||||
oldText.codeUnitAt(start) == newText.codeUnitAt(start)) {
|
||||
start++;
|
||||
}
|
||||
|
||||
// SAFETY: If we stopped inside a Surrogate Pair (High Surrogate), backtrack.
|
||||
if (start > 0 && start < oldText.length && start < newText.length) {
|
||||
final prevCode = oldText.codeUnitAt(start - 1);
|
||||
if (prevCode >= 0xD800 && prevCode <= 0xDBFF) {
|
||||
start--;
|
||||
}
|
||||
}
|
||||
|
||||
// B. Find Common Suffix
|
||||
int oldEnd = oldText.length;
|
||||
int newEnd = newText.length;
|
||||
|
||||
while (oldEnd > start &&
|
||||
newEnd > start &&
|
||||
oldText.codeUnitAt(oldEnd - 1) == newText.codeUnitAt(newEnd - 1)) {
|
||||
oldEnd--;
|
||||
newEnd--;
|
||||
}
|
||||
|
||||
// SAFETY: If we stopped inside a Surrogate Pair (Low Surrogate), expand the change area.
|
||||
if (oldEnd < oldText.length && newEnd < newText.length) {
|
||||
final code = oldText.codeUnitAt(oldEnd);
|
||||
if (code >= 0xDC00 && code <= 0xDFFF) {
|
||||
oldEnd++;
|
||||
newEnd++;
|
||||
}
|
||||
}
|
||||
|
||||
final deleteCount = oldEnd - start;
|
||||
final insertText = newText.substring(start, newEnd);
|
||||
|
||||
return TextSplice(start, deleteCount, insertText);
|
||||
}
|
||||
|
||||
static bool _areValuesEqual(dynamic a, dynamic b) {
|
||||
if (a == b) return true;
|
||||
if (a is List && b is List) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) if (a[i] != b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to serialize the diff for the UI/Network
|
||||
String serializeDiff(Map<String, dynamic> diff) {
|
||||
// We use a custom encoder to handle our TextSplice and Deleted objects
|
||||
return const JsonEncoder.withIndent(' ').convert(
|
||||
jsonDecode(
|
||||
jsonEncode(
|
||||
diff,
|
||||
toEncodable: (obj) {
|
||||
if (obj is TextSplice) return obj.toJson();
|
||||
if (obj is Deleted) return obj.toJson();
|
||||
return obj;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
name: diff_algorithims
|
||||
description: A set of diffing algorithims for Dart
|
||||
version: 0.0.1
|
||||
homepage: https://github.com/example/pocketbase_sync
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.0
|
||||
flutter: ^3.38.5
|
||||
dev_dependencies:
|
||||
test: ^1.26.3
|
||||
@@ -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