First commit of pkg/observable
diff --git a/.analysis_options b/.analysis_options
new file mode 100644
index 0000000..dbb6342
--- /dev/null
+++ b/.analysis_options
@@ -0,0 +1,49 @@
+analyzer:
+ strong-mode: true
+linter:
+ rules:
+ #- always_declare_return_types
+ #- always_specify_types
+ #- annotate_overrides
+ #- avoid_as
+ - avoid_empty_else
+ - avoid_init_to_null
+ - avoid_return_types_on_setters
+ - await_only_futures
+ - camel_case_types
+ - cancel_subscriptions
+ #- close_sinks
+ #- comment_references
+ - constant_identifier_names
+ - control_flow_in_finally
+ - empty_catches
+ - empty_constructor_bodies
+ - empty_statements
+ - hash_and_equals
+ - implementation_imports
+ - iterable_contains_unrelated_type
+ - library_names
+ - library_prefixes
+ - list_remove_unrelated_type
+ - non_constant_identifier_names
+ - one_member_abstracts
+ - only_throw_errors
+ - overridden_fields
+ - package_api_docs
+ - package_names
+ - package_prefixed_library_names
+ - prefer_is_not_empty
+ #- public_member_api_docs
+ #- slash_for_doc_comments
+ #- sort_constructors_first
+ #- sort_unnamed_constructors_first
+ - super_goes_last
+ - test_types_in_equals
+ - throw_in_finally
+ #- type_annotate_public_apis
+ - type_init_formals
+ #- unawaited_futures
+ - unnecessary_brace_in_string_interp
+ - unnecessary_getters_setters
+ - unrelated_type_equality_checks
+ - valid_regexps
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..96bc6bb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+# Files and directories created by pub
+.packages
+.pub/
+packages
+pubspec.lock
+
+# Directory created by dartdoc
+doc/api/
diff --git a/AUTHORS b/AUTHORS
new file mode 100644
index 0000000..0617765
--- /dev/null
+++ b/AUTHORS
@@ -0,0 +1,9 @@
+# Names should be added to this file with this pattern:
+#
+# For individuals:
+# Name <email address>
+#
+# For organizations:
+# Organization <fnmatch pattern>
+#
+Google Inc. <*@google.com>
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6f5e0ea
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,33 @@
+Want to contribute? Great! First, read this page (including the small print at
+the end).
+
+### Before you contribute
+Before we can use your code, you must sign the
+[Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual)
+(CLA), which you can do online. The CLA is necessary mainly because you own the
+copyright to your changes, even after your contribution becomes part of our
+codebase, so we need your permission to use and distribute your code. We also
+need to be sure of various other things—for instance that you'll tell us if you
+know that your code infringes on other people's patents. You don't have to sign
+the CLA until after you've submitted your code for review and a member has
+approved it, but you must do it before we can put your code into our codebase.
+
+Before you start working on a larger contribution, you should get in touch with
+us first through the issue tracker with your idea so that we can help out and
+possibly guide you. Coordinating up front makes it much easier to avoid
+frustration later on.
+
+### Code reviews
+All submissions, including submissions by project members, require review.
+
+### File headers
+All files in the project must start with the following header.
+
+ // Copyright (c) 2015, 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.
+
+### The small print
+Contributions made by corporations are covered by a different agreement than the
+one above, the
+[Software Grant and Corporate Contributor License Agreement](https://developers.google.com/open-source/cla/corporate).
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..82e9b52
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,26 @@
+Copyright 2016, the Dart project authors. All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/PATENTS b/PATENTS
new file mode 100644
index 0000000..6954196
--- /dev/null
+++ b/PATENTS
@@ -0,0 +1,23 @@
+Additional IP Rights Grant (Patents)
+
+"This implementation" means the copyrightable works distributed by
+Google as part of the Dart Project.
+
+Google 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, transfer, and otherwise run, modify and propagate the contents
+of this implementation of Dart, where such license applies only to
+those patent claims, both currently owned by Google and acquired in
+the future, licensable by Google that are necessarily infringed by
+this implementation of Dart. This grant does not include claims that
+would be infringed only as a consequence of further modification of
+this implementation. If you or your agent or exclusive licensee
+institute or order or agree to the institution of patent litigation
+against any entity (including a cross-claim or counterclaim in a
+lawsuit) alleging that this implementation of Dart or any code
+incorporated within this implementation of Dart constitutes direct or
+contributory patent infringement, or inducement of patent
+infringement, then any patent rights granted to you under this License
+for this implementation of Dart shall terminate as of the date such
+litigation is filed.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3965a6f
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+Support for marking objects as observable, and getting notifications when those
+objects are mutated.
+
+This library is used to observe changes to observable types. It also
+has helpers to make implementing and using observable objects easy.
diff --git a/lib/observable.dart b/lib/observable.dart
new file mode 100644
index 0000000..ce82061
--- /dev/null
+++ b/lib/observable.dart
@@ -0,0 +1,13 @@
+// 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;
+
+export 'src/change_record.dart';
+export 'src/list_diff.dart' show ListChangeRecord;
+export 'src/observable.dart';
+export 'src/observable_list.dart';
+export 'src/observable_map.dart';
+export 'src/property_change_record.dart';
+export 'src/to_observable.dart';
diff --git a/lib/src/change_record.dart b/lib/src/change_record.dart
new file mode 100644
index 0000000..b4c4f24
--- /dev/null
+++ b/lib/src/change_record.dart
@@ -0,0 +1,9 @@
+// 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.change_record;
+
+/// Records a change to an [Observable].
+// TODO(jmesserly): remove this type
+abstract class ChangeRecord {}
diff --git a/lib/src/list_diff.dart b/lib/src/list_diff.dart
new file mode 100644
index 0000000..c16a613
--- /dev/null
+++ b/lib/src/list_diff.dart
@@ -0,0 +1,398 @@
+// 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.list_diff;
+
+import 'dart:collection' show UnmodifiableListView;
+import 'dart:math' as math;
+
+import 'change_record.dart' show ChangeRecord;
+
+/// A summary of an individual change to a [List].
+///
+/// Each delta represents that at the [index], [removed] sequence of items were
+/// removed, and counting forward from [index], [addedCount] items were added.
+class ListChangeRecord /* TODO(tvolkert): remove */ extends ChangeRecord {
+ /// The list that changed.
+ final List object;
+
+ /// The index of the change.
+ int get index => _index;
+
+ /// The items removed, if any. Otherwise this will be an empty list.
+ List get removed => _unmodifiableRemoved;
+ UnmodifiableListView _unmodifiableRemoved;
+
+ /// Mutable version of [removed], used during the algorithms as they are
+ /// constructing the object.
+ List _removed;
+
+ /// The number of items added.
+ int get addedCount => _addedCount;
+
+ // Note: conceptually these are final, but for convenience we increment it as
+ // we build the object. It will be "frozen" by the time it is returned the the
+ // user.
+ int _index, _addedCount;
+
+ ListChangeRecord._(this.object, this._index, List removed, this._addedCount)
+ : _removed = removed,
+ _unmodifiableRemoved = new UnmodifiableListView(removed);
+
+ factory ListChangeRecord(List object, int index,
+ {List removed, int addedCount}) {
+ if (removed == null) removed = [];
+ if (addedCount == null) addedCount = 0;
+ return new ListChangeRecord._(object, index, removed, addedCount);
+ }
+
+ /// Returns true if the provided [ref] index was changed by this operation.
+ bool indexChanged(int ref) {
+ // If ref is before the index, then it wasn't changed.
+ if (ref < index) return false;
+
+ // If this was a shift operation, anything after index is changed.
+ if (addedCount != removed.length) return true;
+
+ // Otherwise, anything in the update range was changed.
+ return ref < index + addedCount;
+ }
+
+ String toString() => '#<ListChangeRecord index: $index, '
+ 'removed: $removed, addedCount: $addedCount>';
+}
+
+// Note: This function is *based* on the computation of the Levenshtein
+// "edit" distance. The one change is that "updates" are treated as two
+// edits - not one. With List splices, an update is really a delete
+// followed by an add. By retaining this, we optimize for "keeping" the
+// maximum array items in the original array. For example:
+//
+// 'xxxx123' -> '123yyyy'
+//
+// With 1-edit updates, the shortest path would be just to update all seven
+// characters. With 2-edit updates, we delete 4, leave 3, and add 4. This
+// leaves the substring '123' intact.
+List<List<int>> _calcEditDistances(List current, int currentStart,
+ int currentEnd, List old, int oldStart, int oldEnd) {
+ // "Deletion" columns
+ int rowCount = oldEnd - oldStart + 1;
+ int columnCount = currentEnd - currentStart + 1;
+ List<List<int>> distances = new List<List<int>>(rowCount);
+
+ // "Addition" rows. Initialize null column.
+ for (int i = 0; i < rowCount; i++) {
+ distances[i] = new List(columnCount);
+ distances[i][0] = i;
+ }
+
+ // Initialize null row
+ for (int j = 0; j < columnCount; j++) {
+ distances[0][j] = j;
+ }
+
+ for (int i = 1; i < rowCount; i++) {
+ for (int j = 1; j < columnCount; j++) {
+ if (old[oldStart + i - 1] == current[currentStart + j - 1]) {
+ distances[i][j] = distances[i - 1][j - 1];
+ } else {
+ int north = distances[i - 1][j] + 1;
+ int west = distances[i][j - 1] + 1;
+ distances[i][j] = math.min(north, west);
+ }
+ }
+ }
+
+ return distances;
+}
+
+const _kEditLeave = 0;
+const _kEditUpdate = 1;
+const _kEditAdd = 2;
+const _kEditDelete = 3;
+
+// This starts at the final weight, and walks "backward" by finding
+// the minimum previous weight recursively until the origin of the weight
+// matrix.
+List<int> _spliceOperationsFromEditDistances(List<List<int>> distances) {
+ int i = distances.length - 1;
+ int j = distances[0].length - 1;
+ int current = distances[i][j];
+ List<int> edits = <int>[];
+ while (i > 0 || j > 0) {
+ if (i == 0) {
+ edits.add(_kEditAdd);
+ j--;
+ continue;
+ }
+ if (j == 0) {
+ edits.add(_kEditDelete);
+ i--;
+ continue;
+ }
+ int northWest = distances[i - 1][j - 1];
+ int west = distances[i - 1][j];
+ int north = distances[i][j - 1];
+
+ int min = math.min(math.min(west, north), northWest);
+
+ if (min == northWest) {
+ if (northWest == current) {
+ edits.add(_kEditLeave);
+ } else {
+ edits.add(_kEditUpdate);
+ current = northWest;
+ }
+ i--;
+ j--;
+ } else if (min == west) {
+ edits.add(_kEditDelete);
+ i--;
+ current = west;
+ } else {
+ edits.add(_kEditAdd);
+ j--;
+ current = north;
+ }
+ }
+
+ return edits.reversed.toList();
+}
+
+int _sharedPrefix(List arr1, List arr2, int searchLength) {
+ for (int i = 0; i < searchLength; i++) {
+ if (arr1[i] != arr2[i]) {
+ return i;
+ }
+ }
+ return searchLength;
+}
+
+int _sharedSuffix(List arr1, List arr2, int searchLength) {
+ int index1 = arr1.length;
+ int index2 = arr2.length;
+ int count = 0;
+ while (count < searchLength && arr1[--index1] == arr2[--index2]) {
+ count++;
+ }
+ return count;
+}
+
+/// Lacking individual splice mutation information, the minimal set of
+/// splices can be synthesized given the previous state and final state of an
+/// array. The basic approach is to calculate the edit distance matrix and
+/// choose the shortest path through it.
+///
+/// Complexity: O(l * p)
+/// l: The length of the current array
+/// p: The length of the old array
+List<ListChangeRecord> calcSplices(List current, int currentStart,
+ int currentEnd, List old, int oldStart, int oldEnd) {
+ int prefixCount = 0;
+ int suffixCount = 0;
+
+ int minLength = math.min(currentEnd - currentStart, oldEnd - oldStart);
+ if (currentStart == 0 && oldStart == 0) {
+ prefixCount = _sharedPrefix(current, old, minLength);
+ }
+
+ if (currentEnd == current.length && oldEnd == old.length) {
+ suffixCount = _sharedSuffix(current, old, minLength - prefixCount);
+ }
+
+ currentStart += prefixCount;
+ oldStart += prefixCount;
+ currentEnd -= suffixCount;
+ oldEnd -= suffixCount;
+
+ if (currentEnd - currentStart == 0 && oldEnd - oldStart == 0) {
+ return const [];
+ }
+
+ if (currentStart == currentEnd) {
+ ListChangeRecord splice = new ListChangeRecord(current, currentStart);
+ while (oldStart < oldEnd) {
+ splice._removed.add(old[oldStart++]);
+ }
+
+ return [splice];
+ } else if (oldStart == oldEnd) {
+ return [
+ new ListChangeRecord(current, currentStart,
+ addedCount: currentEnd - currentStart)
+ ];
+ }
+
+ List<int> ops = _spliceOperationsFromEditDistances(_calcEditDistances(
+ current, currentStart, currentEnd, old, oldStart, oldEnd));
+
+ ListChangeRecord splice;
+ List<ListChangeRecord> splices = <ListChangeRecord>[];
+ int index = currentStart;
+ int oldIndex = oldStart;
+ for (int i = 0; i < ops.length; i++) {
+ switch (ops[i]) {
+ case _kEditLeave:
+ if (splice != null) {
+ splices.add(splice);
+ splice = null;
+ }
+
+ index++;
+ oldIndex++;
+ break;
+ case _kEditUpdate:
+ if (splice == null) splice = new ListChangeRecord(current, index);
+
+ splice._addedCount++;
+ index++;
+
+ splice._removed.add(old[oldIndex]);
+ oldIndex++;
+ break;
+ case _kEditAdd:
+ if (splice == null) splice = new ListChangeRecord(current, index);
+
+ splice._addedCount++;
+ index++;
+ break;
+ case _kEditDelete:
+ if (splice == null) splice = new ListChangeRecord(current, index);
+
+ splice._removed.add(old[oldIndex]);
+ oldIndex++;
+ break;
+ }
+ }
+
+ if (splice != null) splices.add(splice);
+ return splices;
+}
+
+int _intersect(int start1, int end1, int start2, int end2) =>
+ math.min(end1, end2) - math.max(start1, start2);
+
+void _mergeSplice(List<ListChangeRecord> splices, ListChangeRecord record) {
+ ListChangeRecord splice = new ListChangeRecord(record.object, record.index,
+ removed: record._removed.toList(), addedCount: record.addedCount);
+
+ bool inserted = false;
+ int insertionOffset = 0;
+
+ // I think the way this works is:
+ // - the loop finds where the merge should happen
+ // - it applies the merge in a particular splice
+ // - then continues and updates the subsequent splices with any offset diff.
+ for (int i = 0; i < splices.length; i++) {
+ final ListChangeRecord current = splices[i];
+ current._index += insertionOffset;
+
+ if (inserted) continue;
+
+ int intersectCount = _intersect(
+ splice.index,
+ splice.index + splice.removed.length,
+ current.index,
+ current.index + current.addedCount);
+
+ if (intersectCount >= 0) {
+ // Merge the two splices
+
+ splices.removeAt(i);
+ i--;
+
+ insertionOffset -= current.addedCount - current.removed.length;
+
+ splice._addedCount += current.addedCount - intersectCount;
+ int deleteCount =
+ splice.removed.length + current.removed.length - intersectCount;
+
+ if (splice.addedCount == 0 && deleteCount == 0) {
+ // merged splice is a noop. discard.
+ inserted = true;
+ } else {
+ List removed = current._removed;
+
+ if (splice.index < current.index) {
+ // some prefix of splice.removed is prepended to current.removed.
+ removed.insertAll(
+ 0, splice.removed.getRange(0, current.index - splice.index));
+ }
+
+ if (splice.index + splice.removed.length >
+ current.index + current.addedCount) {
+ // some suffix of splice.removed is appended to current.removed.
+ removed.addAll(splice.removed.getRange(
+ current.index + current.addedCount - splice.index,
+ splice.removed.length));
+ }
+
+ splice._removed = removed;
+ splice._unmodifiableRemoved = current._unmodifiableRemoved;
+ if (current.index < splice.index) {
+ splice._index = current.index;
+ }
+ }
+ } else if (splice.index < current.index) {
+ // Insert splice here.
+
+ inserted = true;
+
+ splices.insert(i, splice);
+ i++;
+
+ int offset = splice.addedCount - splice.removed.length;
+ current._index += offset;
+ insertionOffset += offset;
+ }
+ }
+
+ if (!inserted) splices.add(splice);
+}
+
+List<ListChangeRecord> _createInitialSplices(
+ List<Object> list, List<ListChangeRecord> records) {
+ List<ListChangeRecord> splices = [];
+ for (ListChangeRecord record in records) {
+ _mergeSplice(splices, record);
+ }
+ return splices;
+}
+
+/// We need to summarize change records. Consumers of these records want to
+/// apply the batch sequentially, and ensure that they can find inserted
+/// items by looking at that position in the list. This property does not
+/// hold in our record-as-you-go records. Consider:
+///
+/// var model = toObservable(['a', 'b']);
+/// model.removeAt(1);
+/// model.insertAll(0, ['c', 'd', 'e']);
+/// model.removeRange(1, 3);
+/// model.insert(1, 'f');
+///
+/// Here, we inserted some records and then removed some of them.
+/// If someone processed these records naively, they would "play back" the
+/// insert incorrectly, because those items will be shifted.
+List<ListChangeRecord> projectListSplices(
+ List<Object> list, List<ListChangeRecord> records) {
+ if (records.length <= 1) return records;
+
+ List<ListChangeRecord> splices = <ListChangeRecord>[];
+ for (ListChangeRecord splice in _createInitialSplices(list, records)) {
+ if (splice.addedCount == 1 && splice.removed.length == 1) {
+ if (splice.removed[0] != list[splice.index]) splices.add(splice);
+ continue;
+ }
+
+ splices.addAll(calcSplices(
+ list,
+ splice.index,
+ splice.index + splice.addedCount,
+ splice._removed,
+ 0,
+ splice.removed.length));
+ }
+
+ return splices;
+}
diff --git a/lib/src/observable.dart b/lib/src/observable.dart
new file mode 100644
index 0000000..7d1ec6f
--- /dev/null
+++ b/lib/src/observable.dart
@@ -0,0 +1,106 @@
+// 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;
+
+import 'dart:async';
+import 'dart:collection' show UnmodifiableListView;
+
+import 'package:meta/meta.dart';
+
+import 'change_record.dart' show ChangeRecord;
+import 'property_change_record.dart' show PropertyChangeRecord;
+
+/// Represents an object with observable properties. This is used by data in
+/// model-view architectures to notify interested parties of [changes] to the
+/// object's properties (fields or getter/setter pairs).
+///
+/// The interface does not require any specific technique to implement
+/// observability. You can implement it in the following ways:
+///
+/// - Deriving from this class via a mixin or base class. When a field,
+/// property, or indexable item is changed, the derived class should call
+/// [notifyPropertyChange]. See that method for an example.
+/// - Implementing this interface and providing your own implementation.
+abstract class Observable {
+ StreamController<List<ChangeRecord>> _changes;
+
+ List<ChangeRecord> _records;
+
+ /// The stream of property change records to this object, delivered
+ /// asynchronously.
+ ///
+ /// [deliverChanges] can be called to force synchronous delivery.
+ Stream<List<ChangeRecord>> get changes {
+ if (_changes == null) {
+ _changes = new StreamController.broadcast(
+ sync: true, onListen: observed, onCancel: unobserved);
+ }
+ return _changes.stream;
+ }
+
+ /// Derived classes may override this method to be called when the [changes]
+ /// are first observed.
+ // TODO(tvolkert): @mustCallSuper (github.com/dart-lang/sdk/issues/27275)
+ @protected
+ void observed() {}
+
+ /// Derived classes may override this method to be called when the [changes]
+ /// are no longer being observed.
+ // TODO(tvolkert): @mustCallSuper (github.com/dart-lang/sdk/issues/27275)
+ @protected
+ void unobserved() {
+ // Free some memory
+ _changes = null;
+ }
+
+ /// True if this object has any observers.
+ bool get hasObservers => _changes != null && _changes.hasListener;
+
+ /// Synchronously deliver pending [changes].
+ ///
+ /// Returns `true` if any records were delivered, otherwise `false`.
+ /// Pending records will be cleared regardless, to keep newly added
+ /// observers from being notified of changes that occurred before
+ /// they started observing.
+ bool deliverChanges() {
+ List<ChangeRecord> records = _records;
+ _records = null;
+ if (hasObservers && records != null) {
+ _changes.add(new UnmodifiableListView<ChangeRecord>(records));
+ return true;
+ }
+ return false;
+ }
+
+ /// Notify that the [field] name of this object has been changed.
+ ///
+ /// The [oldValue] and [newValue] are also recorded. If the two values are
+ /// equal, no change will be recorded.
+ ///
+ /// For convenience this returns [newValue].
+ /*=T*/ notifyPropertyChange/*<T>*/(
+ Symbol field, /*=T*/ oldValue, /*=T*/ newValue) {
+ if (hasObservers && oldValue != newValue) {
+ notifyChange(new PropertyChangeRecord(this, field, oldValue, newValue));
+ }
+ return newValue;
+ }
+
+ /// Notify observers of a change.
+ ///
+ /// This will automatically schedule [deliverChanges].
+ ///
+ /// For most objects [Observable.notifyPropertyChange] is more convenient, but
+ /// collections sometimes deliver other types of changes such as a
+ /// [MapChangeRecord].
+ void notifyChange(ChangeRecord record) {
+ if (!hasObservers) return;
+ if (_records == null) {
+ _records = [];
+ scheduleMicrotask(deliverChanges);
+ }
+ _records.add(record);
+ }
+}
diff --git a/lib/src/observable_list.dart b/lib/src/observable_list.dart
new file mode 100644
index 0000000..16c49f5
--- /dev/null
+++ b/lib/src/observable_list.dart
@@ -0,0 +1,306 @@
+// 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 'list_diff.dart' show ListChangeRecord, projectListSplices, calcSplices;
+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;
+
+ int get length => _list.length;
+
+ 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;
+ }
+
+ E operator [](int index) => _list[index];
+
+ 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.
+ bool get isEmpty => super.isEmpty;
+ 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.
+
+ 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);
+ }
+
+ void add(E value) {
+ int len = _list.length;
+ _notifyChangeLength(len, len + 1);
+ if (hasListObservers) {
+ _notifyListChange(len, addedCount: 1);
+ }
+
+ _list.add(value);
+ }
+
+ 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);
+ }
+ }
+
+ bool remove(Object element) {
+ for (int i = 0; i < this.length; i++) {
+ if (this[i] == element) {
+ removeRange(i, i + 1);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+ }
+
+ 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;
+ }
+
+ 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, int addedCount}) {
+ 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(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> calculateChangeRecords(
+ List<Object> oldValue, List<Object> newValue) =>
+ calcSplices(newValue, 0, newValue.length, oldValue, 0, oldValue.length);
+
+ /// 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/observable_map.dart b/lib/src/observable_map.dart
new file mode 100644
index 0000000..23a7f11
--- /dev/null
+++ b/lib/src/observable_map.dart
@@ -0,0 +1,189 @@
+// 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_map;
+
+import 'dart:collection';
+
+import 'change_record.dart' show ChangeRecord;
+import 'observable.dart' show Observable;
+import 'property_change_record.dart' show PropertyChangeRecord;
+
+// TODO(jmesserly): this needs to be faster. We currently require multiple
+// lookups per key to get the old value.
+// TODO(jmesserly): this doesn't implement the precise interfaces like
+// LinkedHashMap, SplayTreeMap or HashMap. However it can use them for the
+// backing store.
+
+// TODO(jmesserly): should we summarize map changes like we do for list changes?
+class MapChangeRecord<K, V> extends ChangeRecord {
+ // TODO(jmesserly): we could store this more compactly if it matters, with
+ // subtypes for inserted and removed.
+
+ /// The map key that changed.
+ final K key;
+
+ /// The previous value associated with this key.
+ final V oldValue;
+
+ /// The new value associated with this key.
+ final V newValue;
+
+ /// True if this key was inserted.
+ final bool isInsert;
+
+ /// True if this key was removed.
+ final bool isRemove;
+
+ MapChangeRecord(this.key, this.oldValue, this.newValue)
+ : isInsert = false,
+ isRemove = false;
+
+ MapChangeRecord.insert(this.key, this.newValue)
+ : isInsert = true,
+ isRemove = false,
+ oldValue = null;
+
+ MapChangeRecord.remove(this.key, this.oldValue)
+ : isInsert = false,
+ isRemove = true,
+ newValue = null;
+
+ String toString() {
+ var kind = isInsert ? 'insert' : isRemove ? 'remove' : 'set';
+ return '#<MapChangeRecord $kind $key from: $oldValue to: $newValue>';
+ }
+}
+
+/// Represents an observable map of model values. If any items are added,
+/// removed, or replaced, then observers that are listening to [changes]
+/// will be notified.
+class ObservableMap<K, V> extends Observable implements Map<K, V> {
+ final Map<K, V> _map;
+
+ /// Creates an observable map.
+ ObservableMap() : _map = new HashMap<K, V>();
+
+ /// Creates a new observable map using a [LinkedHashMap].
+ ObservableMap.linked() : _map = new LinkedHashMap<K, V>();
+
+ /// Creates a new observable map using a [SplayTreeMap].
+ ObservableMap.sorted() : _map = new SplayTreeMap<K, V>();
+
+ /// Creates an observable map that contains all key value pairs of [other].
+ /// It will attempt to use the same backing map type if the other map is a
+ /// [LinkedHashMap], [SplayTreeMap], or [HashMap]. Otherwise it defaults to
+ /// [HashMap].
+ ///
+ /// Note this will perform a shallow conversion. If you want a deep conversion
+ /// you should use [toObservable].
+ factory ObservableMap.from(Map<K, V> other) {
+ return new ObservableMap<K, V>.createFromType(other)..addAll(other);
+ }
+
+ /// Like [ObservableMap.from], but creates an empty map.
+ factory ObservableMap.createFromType(Map<K, V> other) {
+ ObservableMap<K, V> result;
+ if (other is SplayTreeMap) {
+ result = new ObservableMap<K, V>.sorted();
+ } else if (other is LinkedHashMap) {
+ result = new ObservableMap<K, V>.linked();
+ } else {
+ result = new ObservableMap<K, V>();
+ }
+ return result;
+ }
+
+ Iterable<K> get keys => _map.keys;
+
+ Iterable<V> get values => _map.values;
+
+ int get length => _map.length;
+
+ bool get isEmpty => length == 0;
+
+ bool get isNotEmpty => !isEmpty;
+
+ bool containsValue(Object value) => _map.containsValue(value);
+
+ bool containsKey(Object key) => _map.containsKey(key);
+
+ V operator [](Object key) => _map[key];
+
+ void operator []=(K key, V value) {
+ if (!hasObservers) {
+ _map[key] = value;
+ return;
+ }
+
+ int len = _map.length;
+ V oldValue = _map[key];
+
+ _map[key] = value;
+
+ if (len != _map.length) {
+ notifyPropertyChange(#length, len, _map.length);
+ notifyChange(new MapChangeRecord.insert(key, value));
+ _notifyKeysValuesChanged();
+ } else if (oldValue != value) {
+ notifyChange(new MapChangeRecord(key, oldValue, value));
+ _notifyValuesChanged();
+ }
+ }
+
+ void addAll(Map<K, V> other) {
+ other.forEach((K key, V value) {
+ this[key] = value;
+ });
+ }
+
+ V putIfAbsent(K key, V ifAbsent()) {
+ int len = _map.length;
+ V result = _map.putIfAbsent(key, ifAbsent);
+ if (hasObservers && len != _map.length) {
+ notifyPropertyChange(#length, len, _map.length);
+ notifyChange(new MapChangeRecord.insert(key, result));
+ _notifyKeysValuesChanged();
+ }
+ return result;
+ }
+
+ V remove(Object key) {
+ int len = _map.length;
+ V result = _map.remove(key);
+ if (hasObservers && len != _map.length) {
+ notifyChange(new MapChangeRecord.remove(key, result));
+ notifyPropertyChange(#length, len, _map.length);
+ _notifyKeysValuesChanged();
+ }
+ return result;
+ }
+
+ void clear() {
+ int len = _map.length;
+ if (hasObservers && len > 0) {
+ _map.forEach((key, value) {
+ notifyChange(new MapChangeRecord.remove(key, value));
+ });
+ notifyPropertyChange(#length, len, 0);
+ _notifyKeysValuesChanged();
+ }
+ _map.clear();
+ }
+
+ void forEach(void f(K key, V value)) => _map.forEach(f);
+
+ String toString() => Maps.mapToString(this);
+
+ // Note: we don't really have a reasonable old/new value to use here.
+ // But this should fix "keys" and "values" in templates with minimal overhead.
+ void _notifyKeysValuesChanged() {
+ notifyChange(new PropertyChangeRecord(this, #keys, null, null));
+ _notifyValuesChanged();
+ }
+
+ void _notifyValuesChanged() {
+ notifyChange(new PropertyChangeRecord(this, #values, null, null));
+ }
+}
diff --git a/lib/src/property_change_record.dart b/lib/src/property_change_record.dart
new file mode 100644
index 0000000..a09c196
--- /dev/null
+++ b/lib/src/property_change_record.dart
@@ -0,0 +1,27 @@
+// 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.property_change_record;
+
+import 'change_record.dart';
+
+/// A change record to a field of an [Observable] object.
+class PropertyChangeRecord<T> extends ChangeRecord {
+ /// The object that changed.
+ final Object object;
+
+ /// The name of the property that changed.
+ final Symbol name;
+
+ /// The previous value of the property.
+ final T oldValue;
+
+ /// The new value of the property.
+ final T newValue;
+
+ PropertyChangeRecord(this.object, this.name, this.oldValue, this.newValue);
+
+ String toString() =>
+ '#<PropertyChangeRecord $name from: $oldValue to: $newValue>';
+}
diff --git a/lib/src/to_observable.dart b/lib/src/to_observable.dart
new file mode 100644
index 0000000..0a0f316
--- /dev/null
+++ b/lib/src/to_observable.dart
@@ -0,0 +1,49 @@
+// 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.to_observable;
+
+import 'observable.dart' show Observable;
+import 'observable_list.dart' show ObservableList;
+import 'observable_map.dart' show ObservableMap;
+
+/// Converts the [Iterable] or [Map] to an [ObservableList] or [ObservableMap],
+/// respectively. This is a convenience function to make it easier to convert
+/// literals into the corresponding observable collection type.
+///
+/// If [value] is not one of those collection types, or is already [Observable],
+/// it will be returned unmodified.
+///
+/// If [value] is a [Map], the resulting value will use the appropriate kind of
+/// backing map: either [HashMap], [LinkedHashMap], or [SplayTreeMap].
+///
+/// By default this performs a deep conversion, but you can set [deep] to false
+/// for a shallow conversion. This does not handle circular data structures.
+/// If a conversion is peformed, mutations are only observed to the result of
+/// this function. Changing the original collection will not affect it.
+// TODO(jmesserly): ObservableSet?
+toObservable(dynamic value, {bool deep: true}) =>
+ deep ? _toObservableDeep(value) : _toObservableShallow(value);
+
+dynamic _toObservableShallow(dynamic value) {
+ if (value is Observable) return value;
+ if (value is Map) return new ObservableMap.from(value);
+ if (value is Iterable) return new ObservableList.from(value);
+ return value;
+}
+
+dynamic _toObservableDeep(dynamic value) {
+ if (value is Observable) return value;
+ if (value is Map) {
+ var result = new ObservableMap.createFromType(value);
+ value.forEach((k, v) {
+ result[_toObservableDeep(k)] = _toObservableDeep(v);
+ });
+ return result;
+ }
+ if (value is Iterable) {
+ return new ObservableList.from(value.map(_toObservableDeep));
+ }
+ return value;
+}
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 0000000..0bedbf6
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,9 @@
+name: observable
+version: 0.14.0
+author: Dart Team <misc@dartlang.org>
+description: Support for marking objects as observable
+homepage: https://github.com/dart-lang/observable
+environment:
+ sdk: '>=1.19.0 <2.0.0'
+dev_dependencies:
+ test: '^0.12.0'
diff --git a/test/list_change_test.dart b/test/list_change_test.dart
new file mode 100644
index 0000000..b07c0b8
--- /dev/null
+++ b/test/list_change_test.dart
@@ -0,0 +1,268 @@
+// 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';
+
+// This file contains code ported from:
+// https://github.com/rafaelw/ChangeSummary/blob/master/tests/test.js
+
+main() => listChangeTests();
+
+// TODO(jmesserly): port or write array fuzzer tests
+listChangeTests() {
+ StreamSubscription sub;
+ var model;
+
+ tearDown(() {
+ sub.cancel();
+ model = null;
+ });
+
+ _delta(i, r, a) => new ListChangeRecord(model, i, removed: r, addedCount: a);
+
+ test('sequential adds', () {
+ model = toObservable([]);
+ model.add(0);
+
+ var summary;
+ sub = model.listChanges.listen((r) => summary = r);
+
+ model.add(1);
+ model.add(2);
+
+ expect(summary, null);
+ return new Future(() => expectChanges(summary, [_delta(1, [], 2)]));
+ });
+
+ test('List Splice Truncate And Expand With Length', () {
+ model = toObservable(['a', 'b', 'c', 'd', 'e']);
+
+ var summary;
+ sub = model.listChanges.listen((r) => summary = r);
+
+ model.length = 2;
+
+ return new Future(() {
+ expectChanges(summary, [
+ _delta(2, ['c', 'd', 'e'], 0)
+ ]);
+ summary = null;
+ model.length = 5;
+ }).then(newMicrotask).then((_) {
+ expectChanges(summary, [_delta(2, [], 3)]);
+ });
+ });
+
+ group('List deltas can be applied', () {
+ applyAndCheckDeltas(model, copy, changes) => changes.then((summary) {
+ // apply deltas to the copy
+ for (var delta in summary) {
+ copy.removeRange(delta.index, delta.index + delta.removed.length);
+ for (int i = delta.addedCount - 1; i >= 0; i--) {
+ copy.insert(delta.index, model[delta.index + i]);
+ }
+ }
+
+ // Note: compare strings for easier debugging.
+ expect('$copy', '$model', reason: 'summary $summary');
+ });
+
+ test('Contained', () {
+ var model = toObservable(['a', 'b']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeAt(1);
+ model.insertAll(0, ['c', 'd', 'e']);
+ model.removeRange(1, 3);
+ model.insert(1, 'f');
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Delete Empty', () {
+ var model = toObservable([1]);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeAt(0);
+ model.insertAll(0, ['a', 'b', 'c']);
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Right Non Overlap', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeRange(0, 1);
+ model.insert(0, 'e');
+ model.removeRange(2, 3);
+ model.insertAll(2, ['f', 'g']);
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Left Non Overlap', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeRange(3, 4);
+ model.insertAll(3, ['f', 'g']);
+ model.removeRange(0, 1);
+ model.insert(0, 'e');
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Right Adjacent', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeRange(1, 2);
+ model.insert(3, 'e');
+ model.removeRange(2, 3);
+ model.insertAll(0, ['f', 'g']);
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Left Adjacent', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeRange(2, 4);
+ model.insert(2, 'e');
+
+ model.removeAt(1);
+ model.insertAll(1, ['f', 'g']);
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Right Overlap', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeAt(1);
+ model.insert(1, 'e');
+ model.removeAt(1);
+ model.insertAll(1, ['f', 'g']);
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Left Overlap', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeAt(2);
+ model.insertAll(2, ['e', 'f', 'g']);
+ // a b [e f g] d
+ model.removeRange(1, 3);
+ model.insertAll(1, ['h', 'i', 'j']);
+ // a [h i j] f g d
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Prefix And Suffix One In', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.insert(0, 'z');
+ model.add('z');
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Remove First', () {
+ var model = toObservable([16, 15, 15]);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeAt(0);
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Update Remove', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeAt(2);
+ model.insertAll(2, ['e', 'f', 'g']); // a b [e f g] d
+ model[0] = 'h';
+ model.removeAt(1);
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+
+ test('Remove Mid List', () {
+ var model = toObservable(['a', 'b', 'c', 'd']);
+ var copy = model.toList();
+ var changes = model.listChanges.first;
+
+ model.removeAt(2);
+
+ return applyAndCheckDeltas(model, copy, changes);
+ });
+ });
+
+ group('edit distance', () {
+ assertEditDistance(orig, changes, expectedDist) => changes.then((summary) {
+ var actualDistance = 0;
+ for (var delta in summary) {
+ actualDistance += delta.addedCount + delta.removed.length;
+ }
+
+ expect(actualDistance, expectedDist);
+ });
+
+ test('add items', () {
+ var model = toObservable([]);
+ var changes = model.listChanges.first;
+ model.addAll([1, 2, 3]);
+ return assertEditDistance(model, changes, 3);
+ });
+
+ test('trunacte and add, sharing a contiguous block', () {
+ var model = toObservable(['x', 'x', 'x', 'x', '1', '2', '3']);
+ var changes = model.listChanges.first;
+ model.length = 0;
+ model.addAll(['1', '2', '3', 'y', 'y', 'y', 'y']);
+ return assertEditDistance(model, changes, 8);
+ });
+
+ test('truncate and add, sharing a discontiguous block', () {
+ var model = toObservable(['1', '2', '3', '4', '5']);
+ var changes = model.listChanges.first;
+ model.length = 0;
+ model.addAll(['a', '2', 'y', 'y', '4', '5', 'z', 'z']);
+ return assertEditDistance(model, changes, 7);
+ });
+
+ test('insert at beginning and end', () {
+ var model = toObservable([2, 3, 4]);
+ var changes = model.listChanges.first;
+ model.insert(0, 5);
+ model[2] = 6;
+ model.add(7);
+ return assertEditDistance(model, changes, 4);
+ });
+ });
+}
diff --git a/test/observable_list_test.dart b/test/observable_list_test.dart
new file mode 100644
index 0000000..51f07bf
--- /dev/null
+++ b/test/observable_list_test.dart
@@ -0,0 +1,320 @@
+// 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('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('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;
+
+_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_map_test.dart b/test/observable_map_test.dart
new file mode 100644
index 0000000..a424ce1
--- /dev/null
+++ b/test/observable_map_test.dart
@@ -0,0 +1,376 @@
+// 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 Map API tests.
+
+ StreamSubscription sub;
+
+ sharedTearDown() {
+ if (sub != null) {
+ sub.cancel();
+ sub = null;
+ }
+ }
+
+ group('observe length', () {
+ ObservableMap map;
+ List<ChangeRecord> changes;
+
+ setUp(() {
+ map = toObservable({'a': 1, 'b': 2, 'c': 3});
+ changes = null;
+ sub = map.changes.listen((records) {
+ changes = getPropertyChangeRecords(records, #length);
+ });
+ });
+
+ tearDown(sharedTearDown);
+
+ test('add item changes length', () {
+ map['d'] = 4;
+ expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+ return new Future(() {
+ expectChanges(changes, [_lengthChange(map, 3, 4)]);
+ });
+ });
+
+ test('putIfAbsent changes length', () {
+ map.putIfAbsent('d', () => 4);
+ expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+ return new Future(() {
+ expectChanges(changes, [_lengthChange(map, 3, 4)]);
+ });
+ });
+
+ test('remove changes length', () {
+ map.remove('c');
+ map.remove('a');
+ expect(map, {'b': 2});
+ return new Future(() {
+ expectChanges(changes, [
+ _lengthChange(map, 3, 2),
+ _lengthChange(map, 2, 1),
+ ]);
+ });
+ });
+
+ test('remove non-existent item does not change length', () {
+ map.remove('d');
+ expect(map, {'a': 1, 'b': 2, 'c': 3});
+ return new Future(() {
+ expectChanges(changes, null);
+ });
+ });
+
+ test('set existing item does not change length', () {
+ map['c'] = 9000;
+ expect(map, {'a': 1, 'b': 2, 'c': 9000});
+ return new Future(() {
+ expectChanges(changes, []);
+ });
+ });
+
+ test('clear changes length', () {
+ map.clear();
+ expect(map, {});
+ return new Future(() {
+ expectChanges(changes, [_lengthChange(map, 3, 0)]);
+ });
+ });
+ });
+
+ group('observe item', () {
+ ObservableMap map;
+ List<ChangeRecord> changes;
+
+ setUp(() {
+ map = toObservable({'a': 1, 'b': 2, 'c': 3});
+ changes = null;
+ sub = map.changes.listen((records) {
+ changes =
+ records.where((r) => r is MapChangeRecord && r.key == 'b').toList();
+ });
+ });
+
+ tearDown(sharedTearDown);
+
+ test('putIfAbsent new item does not change existing item', () {
+ map.putIfAbsent('d', () => 4);
+ expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+ return new Future(() {
+ expectChanges(changes, []);
+ });
+ });
+
+ test('set item to null', () {
+ map['b'] = null;
+ expect(map, {'a': 1, 'b': null, 'c': 3});
+ return new Future(() {
+ expectChanges(changes, [_changeKey('b', 2, null)]);
+ });
+ });
+
+ test('set item to value', () {
+ map['b'] = 777;
+ expect(map, {'a': 1, 'b': 777, 'c': 3});
+ return new Future(() {
+ expectChanges(changes, [_changeKey('b', 2, 777)]);
+ });
+ });
+
+ test('putIfAbsent does not change if already there', () {
+ map.putIfAbsent('b', () => 1234);
+ expect(map, {'a': 1, 'b': 2, 'c': 3});
+ return new Future(() {
+ expectChanges(changes, null);
+ });
+ });
+
+ test('change a different item', () {
+ map['c'] = 9000;
+ expect(map, {'a': 1, 'b': 2, 'c': 9000});
+ return new Future(() {
+ expectChanges(changes, []);
+ });
+ });
+
+ test('change the item', () {
+ map['b'] = 9001;
+ map['b'] = 42;
+ expect(map, {'a': 1, 'b': 42, 'c': 3});
+ return new Future(() {
+ expectChanges(changes, [
+ _changeKey('b', 2, 9001),
+ _changeKey('b', 9001, 42),
+ ]);
+ });
+ });
+
+ test('remove other items', () {
+ map.remove('a');
+ expect(map, {'b': 2, 'c': 3});
+ return new Future(() {
+ expectChanges(changes, []);
+ });
+ });
+
+ test('remove the item', () {
+ map.remove('b');
+ expect(map, {'a': 1, 'c': 3});
+ return new Future(() {
+ expectChanges(changes, [_removeKey('b', 2)]);
+ });
+ });
+
+ test('remove and add back', () {
+ map.remove('b');
+ map['b'] = 2;
+ expect(map, {'a': 1, 'b': 2, 'c': 3});
+ return new Future(() {
+ expectChanges(changes, [
+ _removeKey('b', 2),
+ _insertKey('b', 2),
+ ]);
+ });
+ });
+ });
+
+ test('toString', () {
+ var map = toObservable({'a': 1, 'b': 2});
+ expect(map.toString(), '{a: 1, b: 2}');
+ });
+
+ group('observe keys/values', () {
+ ObservableMap map;
+ int keysChanged;
+ int valuesChanged;
+
+ setUp(() {
+ map = toObservable({'a': 1, 'b': 2, 'c': 3});
+ keysChanged = 0;
+ valuesChanged = 0;
+ sub = map.changes.listen((records) {
+ keysChanged += getPropertyChangeRecords(records, #keys).length;
+ valuesChanged += getPropertyChangeRecords(records, #values).length;
+ });
+ });
+
+ tearDown(sharedTearDown);
+
+ test('add item changes keys/values', () {
+ map['d'] = 4;
+ expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+ return new Future(() {
+ expect(keysChanged, 1);
+ expect(valuesChanged, 1);
+ });
+ });
+
+ test('putIfAbsent changes keys/values', () {
+ map.putIfAbsent('d', () => 4);
+ expect(map, {'a': 1, 'b': 2, 'c': 3, 'd': 4});
+ return new Future(() {
+ expect(keysChanged, 1);
+ expect(valuesChanged, 1);
+ });
+ });
+
+ test('remove changes keys/values', () {
+ map.remove('c');
+ map.remove('a');
+ expect(map, {'b': 2});
+ return new Future(() {
+ expect(keysChanged, 2);
+ expect(valuesChanged, 2);
+ });
+ });
+
+ test('remove non-existent item does not change keys/values', () {
+ map.remove('d');
+ expect(map, {'a': 1, 'b': 2, 'c': 3});
+ return new Future(() {
+ expect(keysChanged, 0);
+ expect(valuesChanged, 0);
+ });
+ });
+
+ test('set existing item does not change keys', () {
+ map['c'] = 9000;
+ expect(map, {'a': 1, 'b': 2, 'c': 9000});
+ return new Future(() {
+ expect(keysChanged, 0);
+ expect(valuesChanged, 1);
+ });
+ });
+
+ test('clear changes keys/values', () {
+ map.clear();
+ expect(map, {});
+ return new Future(() {
+ expect(keysChanged, 1);
+ expect(valuesChanged, 1);
+ });
+ });
+ });
+
+ group('change records', () {
+ List<ChangeRecord> records;
+ ObservableMap map;
+
+ setUp(() {
+ map = toObservable({'a': 1, 'b': 2});
+ records = null;
+ map.changes.first.then((r) => records = r);
+ });
+
+ tearDown(sharedTearDown);
+
+ test('read operations', () {
+ expect(map.length, 2);
+ expect(map.isEmpty, false);
+ expect(map['a'], 1);
+ expect(map.containsKey(2), false);
+ expect(map.containsValue(2), true);
+ expect(map.containsKey('b'), true);
+ expect(map.keys.toList(), ['a', 'b']);
+ expect(map.values.toList(), [1, 2]);
+ var copy = {};
+ map.forEach((k, v) => copy[k] = v);
+ expect(copy, {'a': 1, 'b': 2});
+ return new Future(() {
+ // no change from read-only operators
+ expect(records, null);
+
+ // Make a change so the subscription gets unregistered.
+ map.clear();
+ });
+ });
+
+ test('putIfAbsent', () {
+ map.putIfAbsent('a', () => 42);
+ expect(map, {'a': 1, 'b': 2});
+
+ map.putIfAbsent('c', () => 3);
+ expect(map, {'a': 1, 'b': 2, 'c': 3});
+
+ return new Future(() {
+ expectChanges(records, [
+ _lengthChange(map, 2, 3),
+ _insertKey('c', 3),
+ _propChange(map, #keys),
+ _propChange(map, #values),
+ ]);
+ });
+ });
+
+ test('[]=', () {
+ map['a'] = 42;
+ expect(map, {'a': 42, 'b': 2});
+
+ map['c'] = 3;
+ expect(map, {'a': 42, 'b': 2, 'c': 3});
+
+ return new Future(() {
+ expectChanges(records, [
+ _changeKey('a', 1, 42),
+ _propChange(map, #values),
+ _lengthChange(map, 2, 3),
+ _insertKey('c', 3),
+ _propChange(map, #keys),
+ _propChange(map, #values),
+ ]);
+ });
+ });
+
+ test('remove', () {
+ map.remove('b');
+ expect(map, {'a': 1});
+
+ return new Future(() {
+ expectChanges(records, [
+ _removeKey('b', 2),
+ _lengthChange(map, 2, 1),
+ _propChange(map, #keys),
+ _propChange(map, #values),
+ ]);
+ });
+ });
+
+ test('clear', () {
+ map.clear();
+ expect(map, {});
+
+ return new Future(() {
+ expectChanges(records, [
+ _removeKey('a', 1),
+ _removeKey('b', 2),
+ _lengthChange(map, 2, 0),
+ _propChange(map, #keys),
+ _propChange(map, #values),
+ ]);
+ });
+ });
+ });
+}
+
+_lengthChange(map, int oldValue, int newValue) =>
+ new PropertyChangeRecord(map, #length, oldValue, newValue);
+
+_changeKey(key, old, newValue) => new MapChangeRecord(key, old, newValue);
+
+_insertKey(key, newValue) => new MapChangeRecord.insert(key, newValue);
+
+_removeKey(key, oldValue) => new MapChangeRecord.remove(key, oldValue);
+
+_propChange(map, prop) => new PropertyChangeRecord(map, prop, null, null);
diff --git a/test/observable_test.dart b/test/observable_test.dart
new file mode 100644
index 0000000..b89d654
--- /dev/null
+++ b/test/observable_test.dart
@@ -0,0 +1,222 @@
+// 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() => observableTests();
+
+void observableTests() {
+ // Track the subscriptions so we can clean them up in tearDown.
+ List subs;
+
+ setUp(() {
+ subs = [];
+ });
+
+ tearDown(() {
+ for (var sub in subs) sub.cancel();
+ });
+
+ test('handle future result', () {
+ var callback = expectAsync(() {});
+ return new Future(callback);
+ });
+
+ test('no observers', () {
+ var t = createModel(123);
+ expect(t.value, 123);
+ t.value = 42;
+ expect(t.value, 42);
+ expect(t.hasObservers, false);
+ });
+
+ test('listen adds an observer', () {
+ var t = createModel(123);
+ expect(t.hasObservers, false);
+
+ subs.add(t.changes.listen((n) {}));
+ expect(t.hasObservers, true);
+ });
+
+ test('changes delived async', () {
+ var t = createModel(123);
+ int called = 0;
+
+ subs.add(t.changes.listen(expectAsync((records) {
+ called++;
+ expectPropertyChanges(records, 2);
+ })));
+
+ t.value = 41;
+ t.value = 42;
+ expect(called, 0);
+ });
+
+ test('cause changes in handler', () {
+ var t = createModel(123);
+ int called = 0;
+
+ subs.add(t.changes.listen(expectAsync((records) {
+ called++;
+ expectPropertyChanges(records, 1);
+ if (called == 1) {
+ // Cause another change
+ t.value = 777;
+ }
+ }, count: 2)));
+
+ t.value = 42;
+ });
+
+ test('multiple observers', () {
+ var t = createModel(123);
+
+ verifyRecords(records) {
+ expectPropertyChanges(records, 2);
+ }
+
+ subs.add(t.changes.listen(expectAsync(verifyRecords)));
+ subs.add(t.changes.listen(expectAsync(verifyRecords)));
+
+ t.value = 41;
+ t.value = 42;
+ });
+
+ test('async processing model', () {
+ var t = createModel(123);
+ var records = [];
+ subs.add(t.changes.listen((r) {
+ records.addAll(r);
+ }));
+ t.value = 41;
+ t.value = 42;
+ expectChanges(records, [], reason: 'changes delived async');
+
+ return new Future(() {
+ expectPropertyChanges(records, 2);
+ records.clear();
+
+ t.value = 777;
+ expectChanges(records, [], reason: 'changes delived async');
+ }).then(newMicrotask).then((_) {
+ expectPropertyChanges(records, 1);
+ });
+ });
+
+ test('cancel listening', () {
+ var t = createModel(123);
+ var sub;
+ sub = t.changes.listen(expectAsync((records) {
+ expectPropertyChanges(records, 1);
+ sub.cancel();
+ t.value = 777;
+ }));
+ t.value = 42;
+ });
+
+ test('cancel and reobserve', () {
+ var t = createModel(123);
+ var sub;
+ sub = t.changes.listen(expectAsync((records) {
+ expectPropertyChanges(records, 1);
+ sub.cancel();
+
+ scheduleMicrotask(() {
+ subs.add(t.changes.listen(expectAsync((records) {
+ expectPropertyChanges(records, 1);
+ })));
+ t.value = 777;
+ });
+ }));
+ t.value = 42;
+ });
+
+ test('cannot modify changes list', () {
+ var t = createModel(123);
+ var records;
+ subs.add(t.changes.listen((r) {
+ records = r;
+ }));
+ t.value = 42;
+
+ return new Future(() {
+ expectPropertyChanges(records, 1);
+
+ // Verify that mutation operations on the list fail:
+
+ expect(() {
+ records[0] = new PropertyChangeRecord(t, #value, 0, 1);
+ }, throwsUnsupportedError);
+
+ expect(() {
+ records.clear();
+ }, throwsUnsupportedError);
+
+ expect(() {
+ records.length = 0;
+ }, throwsUnsupportedError);
+ });
+ });
+
+ test('notifyChange', () {
+ var t = createModel(123);
+ var records = [];
+ subs.add(t.changes.listen((r) {
+ records.addAll(r);
+ }));
+ t.notifyChange(new PropertyChangeRecord(t, #value, 123, 42));
+
+ return new Future(() {
+ expectPropertyChanges(records, 1);
+ expect(t.value, 123, reason: 'value did not actually change.');
+ });
+ });
+
+ test('notifyPropertyChange', () {
+ var t = createModel(123);
+ var records;
+ subs.add(t.changes.listen((r) {
+ records = r;
+ }));
+ expect(t.notifyPropertyChange(#value, t.value, 42), 42,
+ reason: 'notifyPropertyChange returns newValue');
+
+ return new Future(() {
+ expectPropertyChanges(records, 1);
+ expect(t.value, 123, reason: 'value did not actually change.');
+ });
+ });
+}
+
+expectPropertyChanges(records, int number) {
+ expect(records.length, number, reason: 'expected $number change records');
+ for (var record in records) {
+ expect(record is PropertyChangeRecord, true,
+ reason: 'record should be PropertyChangeRecord');
+ expect((record as PropertyChangeRecord).name, #value,
+ reason: 'record should indicate a change to the "value" property');
+ }
+}
+
+createModel(int number) => new ObservableSubclass(number);
+
+class ObservableSubclass<T> extends Observable {
+ ObservableSubclass([T initialValue]) : _value = initialValue;
+
+ T get value => _value;
+ set value(T newValue) {
+ T oldValue = _value;
+ _value = newValue;
+ notifyPropertyChange(#value, oldValue, newValue);
+ }
+
+ T _value;
+
+ String toString() => '#<$runtimeType value: $value>';
+}
diff --git a/test/observable_test_utils.dart b/test/observable_test_utils.dart
new file mode 100644
index 0000000..91d4d8f
--- /dev/null
+++ b/test/observable_test_utils.dart
@@ -0,0 +1,32 @@
+// 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.test.observable_test_utils;
+
+import 'dart:async';
+
+import 'package:observable/observable.dart';
+import 'package:test/test.dart';
+
+/// A small method to help readability. Used to cause the next "then" in a chain
+/// to happen in the next microtask:
+///
+/// future.then(newMicrotask).then(...)
+///
+/// Uses [mu].
+newMicrotask(_) => new Future.value();
+
+// 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);
+
+List<ListChangeRecord> getListChangeRecords(
+ List<ListChangeRecord> changes, int index) =>
+ new List.from(changes.where((ListChangeRecord c) => c.indexChanged(index)));
+
+List<PropertyChangeRecord> getPropertyChangeRecords(
+ List<ChangeRecord> changes, Symbol property) =>
+ new List.from(changes.where(
+ (ChangeRecord c) => c is PropertyChangeRecord && c.name == property));