Refactor and deprecate parts of ObservableList... (#12)
* Refactor ObservableList, start more deprecations.
* More tests and fixes.
* Fix remaining tests.
* Add changelog for 0.18
* Dartfmt.
* Fix bad merge.
* Tiny changes.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6f9d8f..b41cd9a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,21 @@
+## 0.18.0
+
+* Refactor and deprecate `ObservableList`-specific API
+ * `ObservableList.applyChangeRecords`
+ * `ObservableList.calculateChangeRecords`
+ * `ObservableList.withLength`
+ * `ObservableList.deliverListChanges`
+ * `ObservableList.discardListChanges`
+ * `ObservableList.hasListChanges`
+ * `ObservableList.listChanges`
+ * `ObservableList.notifyListChange`
+* Potentially breaking: `ObservableList` may no longer be extended
+
+It is also considered deprecated to be notified of `length`, `isEmpty`
+and `isNotEmpty` `PropertyChangeRecord`s on `ObservableList` - in a
+future release `ObservableList.changes` will be
+`Stream<List<ListChangeRecord>>`.
+
## 0.17.0+1
* Revert `PropertyChangeMixin`, which does not work in dart2js
diff --git a/lib/observable.dart b/lib/observable.dart
index 516ff70..a2d1fca 100644
--- a/lib/observable.dart
+++ b/lib/observable.dart
@@ -5,10 +5,10 @@
library observable;
export 'src/change_notifier.dart' show ChangeNotifier, PropertyChangeNotifier;
+export 'src/collections.dart' show ObservableList;
export 'src/differs.dart' show Differ, EqualityDiffer, ListDiffer, MapDiffer;
export 'src/records.dart'
show ChangeRecord, ListChangeRecord, MapChangeRecord, PropertyChangeRecord;
export 'src/observable.dart';
-export 'src/observable_list.dart';
export 'src/observable_map.dart';
export 'src/to_observable.dart';
diff --git a/lib/src/change_notifier.dart b/lib/src/change_notifier.dart
index f60eb16..775df10 100644
--- a/lib/src/change_notifier.dart
+++ b/lib/src/change_notifier.dart
@@ -49,7 +49,6 @@
///
/// Returns `true` if changes were emitted.
@override
- @protected
@mustCallSuper
bool deliverChanges() {
List<ChangeRecord> changes;
diff --git a/lib/src/collections.dart b/lib/src/collections.dart
new file mode 100644
index 0000000..7aaff1d
--- /dev/null
+++ b/lib/src/collections.dart
@@ -0,0 +1 @@
+export 'collections/observable_list.dart';
diff --git a/lib/src/collections/observable_list.dart b/lib/src/collections/observable_list.dart
new file mode 100644
index 0000000..5cc643a
--- /dev/null
+++ b/lib/src/collections/observable_list.dart
@@ -0,0 +1,438 @@
+import 'dart:async';
+
+import 'package:collection/collection.dart';
+import 'package:observable/observable.dart';
+import 'package:observable/src/differs.dart';
+
+/// A [List] that broadcasts [changes] to subscribers for efficient mutations.
+///
+/// When client code expects a read heavy/write light workload, it is often more
+/// efficient to notify _when_ something has changed, instead of constantly
+/// diffing lists to find a single change (like an inserted record). You may
+/// accept an observable list to be notified of mutations:
+/// set names(List<String> names) {
+/// clearAndWrite(names);
+/// if (names is ObservableList<String>) {
+/// names.listChanges.listen(smallIncrementalUpdate);
+/// }
+/// }
+///
+/// *See [ListDiffer] to manually diff two lists instead*
+abstract class ObservableList<E> implements List<E>, Observable {
+ /// Applies [changes] to [previous] based on the [current] values.
+ ///
+ /// ## Deprecated
+ ///
+ /// If you need this functionality, copy it into your own library. The only
+ /// known usage is in `package:template_binding` - it will be upgraded before
+ /// removing this method.
+ @Deprecated('Use ListChangeRecord#apply instead')
+ static void applyChangeRecords/*<T>*/(
+ List/*<T>*/ previous,
+ List/*<T>*/ current,
+ List<ListChangeRecord/*<T>*/ > changes,
+ ) {
+ if (identical(previous, current)) {
+ throw new ArgumentError("Can't use same list for previous and current");
+ }
+ for (final change in changes) {
+ change.apply(previous);
+ }
+ }
+
+ /// Calculates change records between [previous] and [current].
+ ///
+ /// ## Deprecated
+ ///
+ /// This was moved into `ListDiffer.diff`.
+ @Deprecated('Use `ListDiffer.diff` instead')
+ static List<ListChangeRecord/*<T>*/ > calculateChangeRecords/*<T>*/(
+ List/*<T>*/ previous,
+ List/*<T>*/ current,
+ ) {
+ return const ListDiffer/*<T>*/().diff(previous, current);
+ }
+
+ /// Creates an observable list of the given [length].
+ factory ObservableList([int length]) {
+ final list = length != null ? new List<E>(length) : <E>[];
+ return new _ObservableDelegatingList(list);
+ }
+
+ /// Create a new observable list.
+ ///
+ /// Optionally define a [list] to use as a backing store.
+ factory ObservableList.delegate([List<E> list]) {
+ return new _ObservableDelegatingList(list ?? <E>[]);
+ }
+
+ /// Create a new observable list from [elements].
+ factory ObservableList.from(Iterable<E> elements) {
+ return new _ObservableDelegatingList(elements.toList());
+ }
+
+ /// Creates a new observable list of the given [length].
+ @Deprecated('Use the default constructor')
+ factory ObservableList.withLength(int length) {
+ return new ObservableList<E>(length);
+ }
+
+ @Deprecated('No longer supported. Just use deliverChanges')
+ bool deliverListChanges();
+
+ @Deprecated('No longer supported')
+ void discardListChanges();
+
+ @Deprecated('The `changes` stream emits ListChangeRecord now')
+ bool get hasListObservers;
+
+ /// A stream of summarized list changes, delivered asynchronously.
+ @Deprecated(''
+ 'The `changes` stream will soon only emit ListChangeRecord; '
+ 'either continue to use this getter until removed, or use the changes '
+ 'stream with a `Stream.where` guard')
+ Stream<List<ListChangeRecord<E>>> get listChanges;
+
+ @Deprecated('Should no longer be used external from ObservableList')
+ void notifyListChange(
+ int index, {
+ List<E> removed: const [],
+ int addedCount: 0,
+ });
+}
+
+class _ObservableDelegatingList<E> extends DelegatingList<E>
+ implements ObservableList<E> {
+ final _listChanges = new ChangeNotifier<ListChangeRecord<E>>();
+ final _propChanges = new ChangeNotifier<PropertyChangeRecord>();
+
+ StreamController<List<ChangeRecord>> _allChanges;
+
+ _ObservableDelegatingList(List<E> store) : super(store);
+
+ // Observable
+
+ @override
+ Stream<List<ChangeRecord>> get changes {
+ if (_allChanges == null) {
+ StreamSubscription listSub;
+ StreamSubscription propSub;
+ _allChanges = new StreamController<List<ChangeRecord>>.broadcast(
+ sync: true,
+ onListen: () {
+ listSub = listChanges.listen(_allChanges.add);
+ propSub = _propChanges.changes.listen(_allChanges.add);
+ },
+ onCancel: () {
+ listSub.cancel();
+ propSub.cancel();
+ });
+ }
+ return _allChanges.stream;
+ }
+
+ // ChangeNotifier (deprecated for ObservableList)
+
+ @override
+ bool deliverChanges() {
+ final deliveredListChanges = _listChanges.deliverChanges();
+ final deliveredPropChanges = _propChanges.deliverChanges();
+ return deliveredListChanges || deliveredPropChanges;
+ }
+
+ @override
+ void discardListChanges() {
+ // This used to do something, but now we just make it a no-op.
+ }
+
+ @override
+ bool get hasObservers {
+ return _listChanges.hasObservers || _propChanges.hasObservers;
+ }
+
+ @override
+ void notifyChange([ChangeRecord change]) {
+ if (change is ListChangeRecord<E>) {
+ _listChanges.notifyChange(change);
+ } else if (change is PropertyChangeRecord) {
+ _propChanges.notifyChange(change);
+ }
+ }
+
+ @override
+ /*=T*/ notifyPropertyChange/*<T>*/(
+ Symbol field,
+ /*=T*/
+ oldValue,
+ /*=T*/
+ newValue,
+ ) {
+ if (oldValue != newValue) {
+ _propChanges.notifyChange(
+ new PropertyChangeRecord/*<T>*/(this, field, oldValue, newValue),
+ );
+ }
+ return newValue;
+ }
+
+ @override
+ void observed() {}
+
+ @override
+ void unobserved() {}
+
+ // ObservableList (deprecated)
+
+ @override
+ bool deliverListChanges() => _listChanges.deliverChanges();
+
+ @override
+ bool get hasListObservers => _listChanges.hasObservers;
+
+ @override
+ Stream<List<ListChangeRecord<E>>> get listChanges {
+ return _listChanges.changes.map((r) => projectListSplices(this, r));
+ }
+
+ @override
+ void notifyListChange(
+ int index, {
+ List<E> removed: const [],
+ int addedCount: 0,
+ }) {
+ _listChanges.notifyChange(new ListChangeRecord<E>(
+ this,
+ index,
+ removed: removed,
+ addedCount: addedCount,
+ ));
+ }
+
+ void _notifyChangeLength(int oldLength, int newLength) {
+ notifyPropertyChange(#length, oldLength, newLength);
+ notifyPropertyChange(#isEmpty, oldLength == 0, newLength == 0);
+ notifyPropertyChange(#isNotEmpty, oldLength != 0, newLength != 0);
+ }
+
+ // List
+
+ @override
+ operator []=(int index, E newValue) {
+ final oldValue = this[index];
+ if (hasObservers && oldValue != newValue) {
+ notifyListChange(index, removed: [oldValue], addedCount: 1);
+ }
+ super[index] = newValue;
+ }
+
+ @override
+ void add(E value) {
+ if (hasObservers) {
+ _notifyChangeLength(length, length + 1);
+ notifyListChange(length, addedCount: 1);
+ }
+ super.add(value);
+ }
+
+ @override
+ void addAll(Iterable<E> values) {
+ final oldLength = this.length;
+ super.addAll(values);
+ final newLength = this.length;
+ final addedCount = newLength - oldLength;
+ if (hasObservers && addedCount > 0) {
+ notifyListChange(oldLength, addedCount: addedCount);
+ _notifyChangeLength(oldLength, newLength);
+ }
+ }
+
+ @override
+ void clear() {
+ if (hasObservers) {
+ notifyListChange(0, removed: toList());
+ _notifyChangeLength(length, 0);
+ }
+ super.clear();
+ }
+
+ @override
+ void fillRange(int start, int end, [E value]) {
+ if (hasObservers) {
+ notifyListChange(
+ start,
+ addedCount: end - start,
+ removed: getRange(start, end).toList(),
+ );
+ }
+ super.fillRange(start, end, value);
+ }
+
+ @override
+ void insert(int index, E element) {
+ super.insert(index, element);
+ if (hasObservers) {
+ notifyListChange(index, addedCount: 1);
+ _notifyChangeLength(length - 1, length);
+ }
+ }
+
+ @override
+ void insertAll(int index, Iterable<E> values) {
+ final oldLength = this.length;
+ super.insertAll(index, values);
+ final newLength = this.length;
+ final addedCount = newLength - oldLength;
+ if (hasObservers && addedCount > 0) {
+ notifyListChange(index, addedCount: addedCount);
+ _notifyChangeLength(oldLength, newLength);
+ }
+ }
+
+ @override
+ set length(int newLength) {
+ final currentLength = this.length;
+ if (currentLength == newLength) {
+ return;
+ }
+ if (hasObservers) {
+ if (newLength < currentLength) {
+ notifyListChange(
+ newLength,
+ removed: getRange(newLength, currentLength).toList(),
+ );
+ } else {
+ notifyListChange(currentLength, addedCount: newLength - currentLength);
+ }
+ }
+ super.length = newLength;
+ if (hasObservers) {
+ _notifyChangeLength(currentLength, newLength);
+ }
+ }
+
+ @override
+ bool remove(Object element) {
+ if (!hasObservers) {
+ return super.remove(element);
+ }
+ for (var i = 0; i < this.length; i++) {
+ if (this[i] == element) {
+ removeAt(i);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @override
+ E removeAt(int index) {
+ if (hasObservers) {
+ final element = this[index];
+ notifyListChange(index, removed: [element]);
+ _notifyChangeLength(length, length - 1);
+ }
+ return super.removeAt(index);
+ }
+
+ @override
+ E removeLast() {
+ final element = super.removeLast();
+ if (hasObservers) {
+ notifyListChange(length, removed: [element]);
+ _notifyChangeLength(length + 1, length);
+ }
+ return element;
+ }
+
+ @override
+ void removeRange(int start, int end) {
+ final rangeLength = end - start;
+ if (hasObservers && rangeLength > 0) {
+ final removed = getRange(start, end).toList();
+ notifyListChange(start, removed: removed);
+ _notifyChangeLength(length, length - removed.length);
+ }
+ super.removeRange(start, end);
+ }
+
+ @override
+ void removeWhere(bool test(E element)) {
+ // We have to re-implement this if we have observers.
+ if (!hasObservers) return super.removeWhere(test);
+
+ // Produce as few change records as possible - if we have multiple removals
+ // in a sequence we want to produce a single record instead of a record for
+ // every element removed.
+ int firstRemovalIndex;
+
+ for (var i = 0; i < length; i++) {
+ var element = this[i];
+ if (test(element)) {
+ if (firstRemovalIndex == null) {
+ // This is the first item we've removed for this sequence.
+ firstRemovalIndex = i;
+ }
+ } else if (firstRemovalIndex != null) {
+ // We have a previous sequence of removals, but are not removing more.
+ removeRange(firstRemovalIndex, i--);
+ firstRemovalIndex = null;
+ }
+ }
+
+ // We have have a pending removal that was never finished.
+ if (firstRemovalIndex != null) {
+ removeRange(firstRemovalIndex, length);
+ }
+ }
+
+ @override
+ void replaceRange(int start, int end, Iterable<E> newContents) {
+ // This could be optimized not to emit two change records but would require
+ // code duplication with these methods. Since this is not used extremely
+ // often in my experience OK to just defer to these methods.
+ removeRange(start, end);
+ insertAll(start, newContents);
+ }
+
+ @override
+ void retainWhere(bool test(E element)) {
+ // This should be functionally the opposite of removeWhere.
+ removeWhere((E element) => !test(element));
+ }
+
+ @override
+ void setAll(int index, Iterable<E> elements) {
+ if (!hasObservers) {
+ super.setAll(index, elements);
+ return;
+ }
+ // Manual invocation of this method is required to get nicer change events.
+ var i = index;
+ final removed = <E>[];
+ for (var e in elements) {
+ removed.add(this[i]);
+ super[i++] = e;
+ }
+ if (removed.isNotEmpty) {
+ notifyListChange(index, removed: removed, addedCount: removed.length);
+ }
+ }
+
+ @override
+ void setRange(int start, int end, Iterable<E> elements, [int skipCount = 0]) {
+ if (!hasObservers) {
+ super.setRange(start, end, elements, skipCount);
+ return;
+ }
+ final iterator = elements.skip(skipCount).iterator..moveNext();
+ final removed = <E>[];
+ for (var i = start; i < end; i++) {
+ removed.add(super[i]);
+ super[i] = iterator.current;
+ iterator.moveNext();
+ }
+ if (removed.isNotEmpty) {
+ notifyListChange(start, removed: removed, addedCount: removed.length);
+ }
+ }
+}
diff --git a/lib/src/observable_list.dart b/lib/src/observable_list.dart
deleted file mode 100644
index 1618ce6..0000000
--- a/lib/src/observable_list.dart
+++ /dev/null
@@ -1,333 +0,0 @@
-// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.
-
-library observable.src.observable_list;
-
-import 'dart:async';
-import 'dart:collection' show ListBase, UnmodifiableListView;
-
-import 'differs.dart';
-import 'records.dart';
-import 'observable.dart' show Observable;
-
-/// Represents an observable list of model values. If any items are added,
-/// removed, or replaced, then observers that are listening to [changes]
-/// will be notified.
-class ObservableList<E> extends ListBase<E> with Observable {
- List<ListChangeRecord> _listRecords;
-
- StreamController<List<ListChangeRecord>> _listChanges;
-
- /// The inner [List<E>] with the actual storage.
- final List<E> _list;
-
- /// Creates an observable list of the given [length].
- ///
- /// If no [length] argument is supplied an extendable list of
- /// length 0 is created.
- ///
- /// If a [length] argument is supplied, a fixed size list of that
- /// length is created.
- ObservableList([int length])
- : _list = length != null ? new List<E>(length) : <E>[];
-
- /// Creates an observable list of the given [length].
- ///
- /// This constructor exists to work around an issue in the VM whereby
- /// classes that derive from [ObservableList] and mixin other classes
- /// require a default generative constructor in the super class that
- /// does not take optional arguments.
- ObservableList.withLength(int length) : this(length);
-
- /// Creates an observable list with the elements of [other]. The order in
- /// the list will be the order provided by the iterator of [other].
- factory ObservableList.from(Iterable<E> other) =>
- new ObservableList<E>()..addAll(other);
-
- /// The stream of summarized list changes, delivered asynchronously.
- ///
- /// Each list change record contains information about an individual mutation.
- /// The records are projected so they can be applied sequentially. For
- /// example, this set of mutations:
- ///
- /// var model = new ObservableList.from(['a', 'b']);
- /// model.listChanges.listen((records) => records.forEach(print));
- /// model.removeAt(1);
- /// model.insertAll(0, ['c', 'd', 'e']);
- /// model.removeRange(1, 3);
- /// model.insert(1, 'f');
- ///
- /// The change records will be summarized so they can be "played back", using
- /// the final list positions to figure out which item was added:
- ///
- /// #<ListChangeRecord index: 0, removed: [], addedCount: 2>
- /// #<ListChangeRecord index: 3, removed: [b], addedCount: 0>
- ///
- /// [deliverChanges] can be called to force synchronous delivery.
- Stream<List<ListChangeRecord>> get listChanges {
- if (_listChanges == null) {
- // TODO(jmesserly): split observed/unobserved notions?
- _listChanges = new StreamController.broadcast(
- sync: true,
- onCancel: () {
- _listChanges = null;
- },
- );
- }
- return _listChanges.stream;
- }
-
- bool get hasListObservers => _listChanges != null && _listChanges.hasListener;
-
- @override
- int get length => _list.length;
-
- @override
- set length(int value) {
- int len = _list.length;
- if (len == value) return;
-
- // Produce notifications if needed
- _notifyChangeLength(len, value);
- if (hasListObservers) {
- if (value < len) {
- _notifyListChange(value, removed: _list.getRange(value, len).toList());
- } else {
- _notifyListChange(len, addedCount: value - len);
- }
- }
-
- _list.length = value;
- }
-
- @override
- E operator [](int index) => _list[index];
-
- @override
- void operator []=(int index, E value) {
- E oldValue = _list[index];
- if (hasListObservers && oldValue != value) {
- _notifyListChange(index, addedCount: 1, removed: [oldValue]);
- }
- _list[index] = value;
- }
-
- // Forwarders so we can reflect on the properties.
- @override
- bool get isEmpty => super.isEmpty;
-
- @override
- bool get isNotEmpty => super.isNotEmpty;
-
- // TODO(jmesserly): should we support first/last/single? They're kind of
- // dangerous to use in a path because they throw exceptions. Also we'd need
- // to produce property change notifications which seems to conflict with our
- // existing list notifications.
-
- // The following methods are here so that we can provide nice change events.
- @override
- void setAll(int index, Iterable<E> iterable) {
- if (iterable is! List && iterable is! Set) {
- iterable = iterable.toList();
- }
- int length = iterable.length;
- if (hasListObservers && length > 0) {
- _notifyListChange(index,
- addedCount: length, removed: _list.sublist(index, length));
- }
- _list.setAll(index, iterable);
- }
-
- @override
- void add(E value) {
- int len = _list.length;
- _notifyChangeLength(len, len + 1);
- if (hasListObservers) {
- _notifyListChange(len, addedCount: 1);
- }
-
- _list.add(value);
- }
-
- @override
- void addAll(Iterable<E> iterable) {
- int len = _list.length;
- _list.addAll(iterable);
-
- _notifyChangeLength(len, _list.length);
-
- int added = _list.length - len;
- if (hasListObservers && added > 0) {
- _notifyListChange(len, addedCount: added);
- }
- }
-
- @override
- bool remove(Object element) {
- for (int i = 0; i < this.length; i++) {
- if (this[i] == element) {
- removeRange(i, i + 1);
- return true;
- }
- }
- return false;
- }
-
- @override
- void removeRange(int start, int end) {
- _rangeCheck(start, end);
- int rangeLength = end - start;
- int len = _list.length;
-
- _notifyChangeLength(len, len - rangeLength);
- if (hasListObservers && rangeLength > 0) {
- _notifyListChange(start, removed: _list.getRange(start, end).toList());
- }
-
- _list.removeRange(start, end);
- }
-
- @override
- void insertAll(int index, Iterable<E> iterable) {
- if (index < 0 || index > length) {
- throw new RangeError.range(index, 0, length);
- }
- // TODO(floitsch): we can probably detect more cases.
- if (iterable is! List && iterable is! Set) {
- iterable = iterable.toList();
- }
- int insertionLength = iterable.length;
- // There might be errors after the length change, in which case the list
- // will end up being modified but the operation not complete. Unless we
- // always go through a "toList" we can't really avoid that.
- int len = _list.length;
- _list.length += insertionLength;
-
- _list.setRange(index + insertionLength, this.length, this, index);
- _list.setAll(index, iterable);
-
- _notifyChangeLength(len, _list.length);
-
- if (hasListObservers && insertionLength > 0) {
- _notifyListChange(index, addedCount: insertionLength);
- }
- }
-
- @override
- void insert(int index, E element) {
- if (index < 0 || index > length) {
- throw new RangeError.range(index, 0, length);
- }
- if (index == length) {
- add(element);
- return;
- }
- // We are modifying the length just below the is-check. Without the check
- // Array.copy could throw an exception, leaving the list in a bad state
- // (with a length that has been increased, but without a new element).
- if (index is! int) throw new ArgumentError(index);
- _list.length++;
- _list.setRange(index + 1, length, this, index);
-
- _notifyChangeLength(_list.length - 1, _list.length);
- if (hasListObservers) {
- _notifyListChange(index, addedCount: 1);
- }
- _list[index] = element;
- }
-
- @override
- E removeAt(int index) {
- E result = this[index];
- removeRange(index, index + 1);
- return result;
- }
-
- void _rangeCheck(int start, int end) {
- if (start < 0 || start > this.length) {
- throw new RangeError.range(start, 0, this.length);
- }
- if (end < start || end > this.length) {
- throw new RangeError.range(end, start, this.length);
- }
- }
-
- void _notifyListChange(
- int index, {
- List removed: const [],
- int addedCount: 0,
- }) {
- if (!hasListObservers) return;
- if (_listRecords == null) {
- _listRecords = [];
- scheduleMicrotask(deliverListChanges);
- }
- _listRecords.add(new ListChangeRecord(
- this,
- index,
- removed: removed,
- addedCount: addedCount,
- ));
- }
-
- void _notifyChangeLength(int oldValue, int newValue) {
- notifyPropertyChange(#length, oldValue, newValue);
- notifyPropertyChange(#isEmpty, oldValue == 0, newValue == 0);
- notifyPropertyChange(#isNotEmpty, oldValue != 0, newValue != 0);
- }
-
- void discardListChanges() {
- // Leave _listRecords set so we don't schedule another delivery.
- if (_listRecords != null) _listRecords = [];
- }
-
- bool deliverListChanges() {
- if (_listRecords == null) return false;
- List<ListChangeRecord> records =
- projectListSplices/*<E>*/(this, _listRecords);
- _listRecords = null;
-
- if (hasListObservers && records.isNotEmpty) {
- _listChanges.add(new UnmodifiableListView<ListChangeRecord>(records));
- return true;
- }
- return false;
- }
-
- /// Calculates the changes to the list, if lacking individual splice mutation
- /// information.
- ///
- /// This is not needed for change records produced by [ObservableList] itself,
- /// but it can be used if the list instance was replaced by another list.
- ///
- /// The minimal set of splices can be synthesized given the previous state and
- /// final state of a list. The basic approach is to calculate the edit
- /// distance matrix and choose the shortest path through it.
- ///
- /// Complexity is `O(l * p)` where `l` is the length of the current list and
- /// `p` is the length of the old list.
- static List<ListChangeRecord/*<E>*/ > calculateChangeRecords/*<E>*/(
- List/*<E>*/ oldValue,
- List/*<E>*/ newValue,
- ) {
- return const ListDiffer/*<E>*/().diff(oldValue, newValue);
- }
-
- /// Updates the [previous] list using the [changeRecords]. For added items,
- /// the [current] list is used to find the current value.
- static void applyChangeRecords(List<Object> previous, List<Object> current,
- List<ListChangeRecord> changeRecords) {
- if (identical(previous, current)) {
- throw new ArgumentError("can't use same list for previous and current");
- }
-
- for (ListChangeRecord change in changeRecords) {
- int addEnd = change.index + change.addedCount;
- int removeEnd = change.index + change.removed.length;
-
- Iterable addedItems = current.getRange(change.index, addEnd);
- previous.replaceRange(change.index, removeEnd, addedItems);
- }
- }
-}
diff --git a/lib/src/records/property_change_record.dart b/lib/src/records/property_change_record.dart
index 74566d5..a606649 100644
--- a/lib/src/records/property_change_record.dart
+++ b/lib/src/records/property_change_record.dart
@@ -26,6 +26,20 @@
);
@override
+ bool operator ==(Object o) {
+ if (o is PropertyChangeRecord<T>) {
+ return identical(object, o.object) &&
+ name == o.name &&
+ oldValue == o.oldValue &&
+ newValue == o.newValue;
+ }
+ return false;
+ }
+
+ @override
+ int get hashCode => hash4(object, name, oldValue, newValue);
+
+ @override
String toString() => ''
'#<$PropertyChangeRecord $name from $oldValue to: $newValue';
}
diff --git a/lib/src/to_observable.dart b/lib/src/to_observable.dart
index 3bf24b6..d117c89 100644
--- a/lib/src/to_observable.dart
+++ b/lib/src/to_observable.dart
@@ -7,7 +7,7 @@
import 'dart:collection';
import 'observable.dart' show Observable;
-import 'observable_list.dart' show ObservableList;
+import 'collections.dart' show ObservableList;
import 'observable_map.dart' show ObservableMap;
/// Converts the [Iterable] or [Map] to an [ObservableList] or [ObservableMap],
diff --git a/pubspec.yaml b/pubspec.yaml
index f326c5d..57ce0ad 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
name: observable
-version: 0.17.0+1
+version: 0.18.0
author: Dart Team <misc@dartlang.org>
description: Support for marking objects as observable
homepage: https://github.com/dart-lang/observable
diff --git a/test/collections/observable_list_test.dart b/test/collections/observable_list_test.dart
new file mode 100644
index 0000000..b8e50d0
--- /dev/null
+++ b/test/collections/observable_list_test.dart
@@ -0,0 +1,444 @@
+import 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+main() {
+ group('$ObservableList', () {
+ group('list api', _runListTests);
+ _runObservableListTests();
+ _runDeprecatedTests();
+ });
+}
+
+bool _hasListChanges(List<ChangeRecord> c) {
+ return c.any((r) => r is ListChangeRecord);
+}
+
+bool _hasPropChanges(List<ChangeRecord> c) {
+ return c.any((r) => r is PropertyChangeRecord);
+}
+
+bool _onlyListChanges(ChangeRecord c) => c is ListChangeRecord;
+
+bool _onlyPropRecords(ChangeRecord c) => c is PropertyChangeRecord;
+
+_runListTests() {
+ // TODO(matanl): Can we run the List-API tests from the SDK?
+ // Any methods actually implemented by ObservableList are below, otherwise I
+ // am relying on the test suite for DelegatingList.
+ test('index set operator', () {
+ final list = new ObservableList<String>(1)..[0] = 'value';
+ expect(list, ['value']);
+ });
+
+ test('add', () {
+ final list = new ObservableList<String>()..add('value');
+ expect(list, ['value']);
+ });
+
+ test('addAll', () {
+ final list = new ObservableList<String>()..addAll(['a', 'b', 'c']);
+ expect(list, ['a', 'b', 'c']);
+ });
+
+ test('clear', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'c'])..clear();
+ expect(list, isEmpty);
+ });
+
+ test('fillRange', () {
+ final list = new ObservableList<String>(5)..fillRange(0, 5, 'a');
+ expect(list, ['a', 'a', 'a', 'a', 'a']);
+ });
+
+ test('insert', () {
+ final list = new ObservableList<String>.from(['a', 'c'])..insert(1, 'b');
+ expect(list, ['a', 'b', 'c']);
+ });
+
+ test('insertAll', () {
+ final list = new ObservableList<String>.from(['c']);
+ list.insertAll(0, ['a', 'b']);
+ expect(list, ['a', 'b', 'c']);
+ });
+
+ test('length', () {
+ final list = new ObservableList<String>()..length = 3;
+ expect(list, [null, null, null]);
+ list.length = 1;
+ expect(list, [null]);
+ list.length = 0;
+ expect(list, isEmpty);
+ });
+
+ test('remove', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'c']);
+ expect(list.remove('b'), isTrue);
+ expect(list, ['a', 'c']);
+ });
+
+ test('removeAt', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'c']);
+ expect(list.removeAt(1), 'b');
+ expect(list, ['a', 'c']);
+ });
+
+ test('removeLast', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'c']);
+ expect(list.removeLast(), 'c');
+ });
+
+ test('removeRange', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'c']);
+ list.removeRange(0, 2);
+ expect(list, ['c']);
+ });
+
+ test('removeWhere', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'a', 'b']);
+ list.removeWhere((v) => v == 'a');
+ expect(list, ['b', 'b']);
+ });
+
+ test('retainWhere', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'a', 'b']);
+ list.retainWhere((v) => v == 'a');
+ expect(list, ['a', 'a']);
+ });
+
+ test('setAll', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'c']);
+ list.setAll(1, ['d', 'f']);
+ expect(list, ['a', 'd', 'f']);
+ });
+
+ test('setRange', () {
+ final list = new ObservableList<String>.from(['a', 'b', 'c']);
+ list.setRange(0, 2, ['d', 'e']);
+ expect(list, ['d', 'e', 'c']);
+ list.setRange(1, 3, ['f', 'g', 'h'], 1);
+ expect(list, ['d', 'g', 'h']);
+ });
+}
+
+// These are the tests we will keep after deprecations occur.
+_runObservableListTests() {
+ group('content changes', () {
+ Completer<List<ListChangeRecord>> completer;
+ List<String> previousState;
+
+ ObservableList<String> list;
+ Stream<List<ChangeRecord>> stream;
+ StreamSubscription sub;
+
+ Future next() {
+ completer = new Completer<List<ListChangeRecord>>.sync();
+ return completer.future;
+ }
+
+ Future<Null> expectChanges(List<ListChangeRecord> changes) {
+ // Applying these change records in order should make the new list.
+ for (final change in changes) {
+ change.apply(previousState);
+ }
+ expect(list, previousState);
+
+ // If these fail, it might be safe to update if optimized/changed.
+ return next().then((actualChanges) {
+ expect(actualChanges, changes);
+ });
+ }
+
+ setUp(() {
+ previousState = ['a', 'b', 'c'];
+ list = new ObservableList<String>.from(previousState);
+ stream = list.changes.where(_hasListChanges);
+ sub = stream.listen((c) {
+ if (completer?.isCompleted == false) {
+ completer.complete(c.where(_onlyListChanges).toList());
+ }
+ previousState = list.toList();
+ });
+ });
+
+ tearDown(() => sub.cancel());
+
+ ListChangeRecord _delta(
+ int index, {
+ List removed: const [],
+ int addedCount: 0,
+ }) {
+ return new ListChangeRecord(
+ list,
+ index,
+ removed: removed,
+ addedCount: addedCount,
+ );
+ }
+
+ test('operator[]=', () async {
+ list[0] = 'd';
+ await expectChanges([
+ _delta(0, removed: ['a'], addedCount: 1),
+ ]);
+ });
+
+ test('add', () async {
+ list.add('d');
+ await expectChanges([
+ _delta(3, addedCount: 1),
+ ]);
+ });
+
+ test('addAll', () async {
+ list.addAll(['d', 'e']);
+ await expectChanges([
+ _delta(3, addedCount: 2),
+ ]);
+ });
+
+ test('clear', () async {
+ list.clear();
+ await expectChanges([
+ _delta(0, removed: ['a', 'b', 'c']),
+ ]);
+ });
+
+ test('fillRange', () async {
+ list.fillRange(1, 3, 'd');
+ await expectChanges([
+ _delta(1, removed: ['b', 'c'], addedCount: 2),
+ ]);
+ });
+
+ test('insert', () async {
+ list.insert(1, 'd');
+ await expectChanges([
+ _delta(1, addedCount: 1),
+ ]);
+ });
+
+ test('insertAll', () async {
+ list.insertAll(1, ['d', 'e']);
+ await expectChanges([
+ _delta(1, addedCount: 2),
+ ]);
+ });
+
+ test('length', () async {
+ list.length = 5;
+ await expectChanges([
+ _delta(3, addedCount: 2),
+ ]);
+ list.length = 1;
+ await expectChanges([
+ _delta(1, removed: ['b', 'c', null, null])
+ ]);
+ });
+
+ test('remove', () async {
+ list.remove('b');
+ await expectChanges([
+ _delta(1, removed: ['b'])
+ ]);
+ });
+
+ test('removeAt', () async {
+ list.removeAt(1);
+ await expectChanges([
+ _delta(1, removed: ['b'])
+ ]);
+ });
+
+ test('removeRange', () async {
+ list.removeRange(0, 2);
+ await expectChanges([
+ _delta(0, removed: ['a', 'b'])
+ ]);
+ });
+
+ test('removeWhere', () async {
+ list.removeWhere((s) => s == 'b');
+ await expectChanges([
+ _delta(1, removed: ['b'])
+ ]);
+ });
+
+ test('replaceRange', () async {
+ list.replaceRange(0, 2, ['d', 'e']);
+ await expectChanges([
+ // Normally would be
+ // _delta(0, removed: ['a', 'b']),
+ // _delta(0, addedCount: 2),
+ // But projectListSplices(...) optimizes to single record
+ _delta(0, removed: ['a', 'b'], addedCount: 2),
+ ]);
+ });
+
+ test('retainWhere', () async {
+ list.retainWhere((s) => s == 'b');
+ await expectChanges([
+ _delta(0, removed: ['a']),
+ _delta(1, removed: ['c']),
+ ]);
+ });
+
+ test('setAll', () async {
+ list.setAll(1, ['d', 'e']);
+ await expectChanges([
+ _delta(1, removed: ['b', 'c'], addedCount: 2),
+ ]);
+ });
+
+ test('setRange', () async {
+ list.setRange(0, 2, ['d', 'e']);
+ await expectChanges([
+ _delta(0, removed: ['a', 'b'], addedCount: 2),
+ ]);
+ list.setRange(1, 3, ['f', 'g', 'h'], 1);
+ await expectChanges([
+ _delta(1, removed: ['e', 'c'], addedCount: 2),
+ ]);
+ });
+ });
+}
+
+// These are tests we will remove after deprecations occur.
+_runDeprecatedTests() {
+ group('length changes', () {
+ Completer<List<ListChangeRecord>> completer;
+ ObservableList<String> list;
+ Stream<List<ChangeRecord>> stream;
+ StreamSubscription sub;
+
+ Future next() {
+ completer = new Completer<List<ListChangeRecord>>.sync();
+ return completer.future;
+ }
+
+ setUp(() {
+ list = new ObservableList<String>.from(['a', 'b', 'c']);
+ stream = list.changes.where(_hasPropChanges);
+ sub = stream.listen((c) {
+ if (completer?.isCompleted == false) {
+ completer.complete(c.where(_onlyPropRecords).toList());
+ }
+ });
+ });
+
+ tearDown(() => sub.cancel());
+
+ PropertyChangeRecord _length(int oldLength, int newLength) {
+ return new PropertyChangeRecord(list, #length, oldLength, newLength);
+ }
+
+ PropertyChangeRecord _isEmpty(bool oldValue, bool newValue) {
+ return new PropertyChangeRecord(list, #isEmpty, oldValue, newValue);
+ }
+
+ PropertyChangeRecord _isNotEmpty(bool oldValue, bool newValue) {
+ return new PropertyChangeRecord(list, #isNotEmpty, oldValue, newValue);
+ }
+
+ test('add', () async {
+ list.add('d');
+ expect(await next(), [
+ _length(3, 4),
+ ]);
+ });
+
+ test('addAll', () async {
+ list.addAll(['d', 'e']);
+ expect(await next(), [
+ _length(3, 5),
+ ]);
+ });
+
+ test('clear', () async {
+ list.clear();
+ expect(await next(), [
+ _length(3, 0),
+ _isEmpty(false, true),
+ _isNotEmpty(true, false),
+ ]);
+ });
+
+ test('insert', () async {
+ list.insert(0, 'd');
+ expect(await next(), [
+ _length(3, 4),
+ ]);
+ });
+
+ test('insertAll', () async {
+ list.insertAll(0, ['d', 'e']);
+ expect(await next(), [
+ _length(3, 5),
+ ]);
+ });
+
+ test('length', () async {
+ list.length = 5;
+ expect(await next(), [
+ _length(3, 5),
+ ]);
+
+ list.length = 0;
+ expect(await next(), [
+ _length(5, 0),
+ _isEmpty(false, true),
+ _isNotEmpty(true, false),
+ ]);
+
+ list.length = 1;
+ expect(await next(), [
+ _length(0, 1),
+ _isEmpty(true, false),
+ _isNotEmpty(false, true),
+ ]);
+ });
+
+ test('remove', () async {
+ list.remove('a');
+ expect(await next(), [
+ _length(3, 2),
+ ]);
+ });
+
+ test('removeAt', () async {
+ list.removeAt(0);
+ expect(await next(), [
+ _length(3, 2),
+ ]);
+ });
+
+ test('removeLast', () async {
+ list.removeLast();
+ expect(await next(), [
+ _length(3, 2),
+ ]);
+ });
+
+ test('removeRange', () async {
+ list.removeRange(0, 2);
+ expect(await next(), [
+ _length(3, 1),
+ ]);
+ });
+
+ test('removeWhere', () async {
+ list.removeWhere((s) => s == 'a');
+ expect(await next(), [
+ _length(3, 2),
+ ]);
+ });
+
+ test('retainWhere', () async {
+ list.retainWhere((s) => s == 'a');
+ expect(await next(), [
+ _length(3, 1),
+ ]);
+ });
+ });
+}
diff --git a/test/observable_list_test.dart b/test/observable_list_test.dart
deleted file mode 100644
index ed87d04..0000000
--- a/test/observable_list_test.dart
+++ /dev/null
@@ -1,342 +0,0 @@
-// Copyright (c) 2016, the Dart project authors. Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.
-
-import 'dart:async';
-
-import 'package:observable/observable.dart';
-import 'package:test/test.dart';
-
-import 'observable_test_utils.dart';
-
-main() => _runTests();
-
-_runTests() {
- // TODO(jmesserly): need all standard List API tests.
-
- StreamSubscription sub, sub2;
-
- sharedTearDown() {
- list = null;
- sub.cancel();
- if (sub2 != null) {
- sub2.cancel();
- sub2 = null;
- }
- }
-
- group('observe length', () {
- ObservableList list;
- List<ChangeRecord> changes;
-
- setUp(() {
- list = toObservable([1, 2, 3]) as ObservableList;
- changes = null;
- sub = list.changes.listen((records) {
- changes = getPropertyChangeRecords(records, #length);
- });
- });
-
- tearDown(sharedTearDown);
-
- test('add changes length', () {
- list.add(4);
- expect(list, [1, 2, 3, 4]);
- return new Future(() {
- expectChanges(changes, [_lengthChange(3, 4)]);
- });
- });
-
- test('removeObject changes length', () {
- list.remove(2);
- expect(list, orderedEquals([1, 3]));
-
- return new Future(() {
- expectChanges(changes, [_lengthChange(3, 2)]);
- });
- });
-
- test('removeRange changes length', () {
- list.add(4);
- list.removeRange(1, 3);
- expect(list, [1, 4]);
- return new Future(() {
- expectChanges(changes, [_lengthChange(3, 4), _lengthChange(4, 2)]);
- });
- });
-
- test('removeWhere changes length', () {
- list.add(2);
- list.removeWhere((e) => e == 2);
- expect(list, [1, 3]);
- return new Future(() {
- expectChanges(changes, [_lengthChange(3, 4), _lengthChange(4, 2)]);
- });
- });
-
- test('length= changes length', () {
- list.length = 5;
- expect(list, [1, 2, 3, null, null]);
- return new Future(() {
- expectChanges(changes, [_lengthChange(3, 5)]);
- });
- });
-
- test('[]= does not change length', () {
- list[2] = 9000;
- expect(list, [1, 2, 9000]);
- return new Future(() {
- expectChanges(changes, null);
- });
- });
-
- test('clear changes length', () {
- list.clear();
- expect(list, []);
- return new Future(() {
- expectChanges(changes, [_lengthChange(3, 0)]);
- });
- });
- });
-
- group('observe index', () {
- List<ListChangeRecord> changes;
-
- setUp(() {
- list = toObservable([1, 2, 3]) as ObservableList;
- changes = null;
- sub = list.listChanges.listen((List<ListChangeRecord> records) {
- changes = getListChangeRecords(records, 1);
- });
- });
-
- tearDown(sharedTearDown);
-
- test('add does not change existing items', () {
- list.add(4);
- expect(list, [1, 2, 3, 4]);
- return new Future(() {
- expectChanges(changes, []);
- });
- });
-
- test('[]= changes item', () {
- list[1] = 777;
- expect(list, [1, 777, 3]);
- return new Future(() {
- expectChanges(changes, [
- _change(1, addedCount: 1, removed: [2])
- ]);
- });
- });
-
- test('[]= on a different item does not fire change', () {
- list[2] = 9000;
- expect(list, [1, 2, 9000]);
- return new Future(() {
- expectChanges(changes, []);
- });
- });
-
- test('set multiple times results in one change', () {
- list[1] = 777;
- list[1] = 42;
- expect(list, [1, 42, 3]);
- return new Future(() {
- expectChanges(changes, [
- _change(1, addedCount: 1, removed: [2]),
- ]);
- });
- });
-
- test('set length without truncating item means no change', () {
- list.length = 2;
- expect(list, [1, 2]);
- return new Future(() {
- expectChanges(changes, []);
- });
- });
-
- test('truncate removes item', () {
- list.length = 1;
- expect(list, [1]);
- return new Future(() {
- expectChanges(changes, [
- _change(1, removed: [2, 3])
- ]);
- });
- });
-
- test('truncate and add new item', () {
- list.length = 1;
- list.add(42);
- expect(list, [1, 42]);
- return new Future(() {
- expectChanges(changes, [
- _change(1, removed: [2, 3], addedCount: 1)
- ]);
- });
- });
-
- test('truncate and add same item', () {
- list.length = 1;
- list.add(2);
- expect(list, [1, 2]);
- return new Future(() {
- expectChanges(changes, []);
- });
- });
- });
-
- test('toString', () {
- var list = toObservable([1, 2, 3]);
- expect(list.toString(), '[1, 2, 3]');
- });
-
- group('change records', () {
- List<ChangeRecord> propRecords;
- List<ListChangeRecord> listRecords;
-
- setUp(() {
- list = toObservable([1, 2, 3, 1, 3, 4]) as ObservableList;
- propRecords = null;
- listRecords = null;
- sub = list.changes.listen((r) => propRecords = r);
- sub2 = list.listChanges.listen((r) => listRecords = r);
- });
-
- tearDown(sharedTearDown);
-
- test('read operations', () {
- expect(list.length, 6);
- expect(list[0], 1);
- expect(list.indexOf(4), 5);
- expect(list.indexOf(1), 0);
- expect(list.indexOf(1, 1), 3);
- expect(list.lastIndexOf(1), 3);
- expect(list.last, 4);
- var copy = new List<int>();
- list.forEach((int i) => copy.add(i));
- expect(copy, orderedEquals([1, 2, 3, 1, 3, 4]));
- return new Future(() {
- // no change from read-only operators
- expectChanges(propRecords, null);
- expectChanges(listRecords, null);
- });
- });
-
- test('add', () {
- list.add(5);
- list.add(6);
- expect(list, orderedEquals([1, 2, 3, 1, 3, 4, 5, 6]));
-
- return new Future(() {
- expectChanges(propRecords, [
- _lengthChange(6, 7),
- _lengthChange(7, 8),
- ]);
- expectChanges(listRecords, [_change(6, addedCount: 2)]);
- });
- });
-
- test('[]=', () {
- list[1] = list.last;
- expect(list, orderedEquals([1, 4, 3, 1, 3, 4]));
-
- return new Future(() {
- expectChanges(propRecords, null);
- expectChanges(listRecords, [
- _change(1, addedCount: 1, removed: [2])
- ]);
- });
- });
-
- test('removeLast', () {
- expect(list.removeLast(), 4);
- expect(list, orderedEquals([1, 2, 3, 1, 3]));
-
- return new Future(() {
- expectChanges(propRecords, [_lengthChange(6, 5)]);
- expectChanges(listRecords, [
- _change(5, removed: [4])
- ]);
- });
- });
-
- test('removeRange', () {
- list.removeRange(1, 4);
- expect(list, orderedEquals([1, 3, 4]));
-
- return new Future(() {
- expectChanges(propRecords, [_lengthChange(6, 3)]);
- expectChanges(listRecords, [
- _change(1, removed: [2, 3, 1])
- ]);
- });
- });
-
- test('removeWhere', () {
- list.removeWhere((e) => e == 3);
- expect(list, orderedEquals([1, 2, 1, 4]));
-
- return new Future(() {
- expectChanges(propRecords, [_lengthChange(6, 4)]);
- expectChanges(listRecords, [
- _change(2, removed: [3]),
- _change(3, removed: [3])
- ]);
- });
- });
-
- test('sort', () {
- list.sort((x, y) => x - y);
- expect(list, orderedEquals([1, 1, 2, 3, 3, 4]));
-
- return new Future(() {
- expectChanges(propRecords, null);
- expectChanges(listRecords, [
- _change(1, addedCount: 1),
- _change(4, removed: [1])
- ]);
- });
- });
-
- test('sort of 2 elements', () {
- var list = toObservable([3, 1]);
- // Dummy listener to record changes.
- // TODO(jmesserly): should we just record changes always, to support the sync api?
- sub = list.listChanges.listen((List<ListChangeRecord> records) => null)
- as StreamSubscription;
- list.sort();
- expect(list.deliverListChanges(), true);
- list.sort();
- expect(list.deliverListChanges(), false);
- list.sort();
- expect(list.deliverListChanges(), false);
- });
-
- test('clear', () {
- list.clear();
- expect(list, []);
-
- return new Future(() {
- expectChanges(propRecords, [
- _lengthChange(6, 0),
- new PropertyChangeRecord(list, #isEmpty, false, true),
- new PropertyChangeRecord(list, #isNotEmpty, true, false),
- ]);
- expectChanges(listRecords, [
- _change(0, removed: [1, 2, 3, 1, 3, 4])
- ]);
- });
- });
- });
-}
-
-ObservableList list;
-
-PropertyChangeRecord _lengthChange(int oldValue, int newValue) =>
- new PropertyChangeRecord(list, #length, oldValue, newValue);
-
-_change(int index, {List removed: const [], int addedCount: 0}) =>
- new ListChangeRecord(list, index, removed: removed, addedCount: addedCount);
diff --git a/test/observable_test_utils.dart b/test/observable_test_utils.dart
index 990cc86..9ce9161 100644
--- a/test/observable_test_utils.dart
+++ b/test/observable_test_utils.dart
@@ -17,8 +17,9 @@
// TODO(jmesserly): use matchers when we have a way to compare ChangeRecords.
// For now just use the toString.
-void expectChanges(actual, expected, {String reason}) =>
- expect('$actual', '$expected', reason: reason);
+void expectChanges(actual, expected, {String reason}) {
+ expect('$actual', '$expected', reason: reason);
+}
List<ListChangeRecord> getListChangeRecords(
List<ListChangeRecord> changes, int index) =>